├── .gitignore ├── README.md ├── bin └── tsserver-ws.js ├── package.json ├── src ├── README.md ├── client.ts ├── completion.ts ├── definition.ts ├── formatting.ts ├── hover.ts ├── index.ts ├── plugin.ts ├── pos.ts ├── references.ts ├── rename.ts ├── signature.ts ├── text.ts ├── theme.ts └── workspace.ts └── test ├── server.ts └── webtest-client.ts /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | package-lock.json 3 | /dist 4 | /test/*.js 5 | /test/*.d.ts 6 | /test/*.d.ts.map 7 | .tern-* 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # @codemirror/lsp-client [![NPM version](https://img.shields.io/npm/v/@codemirror/lsp-client.svg)](https://www.npmjs.org/package/@codemirror/lsp-client) 4 | 5 | [ [**WEBSITE**](https://codemirror.net/) | [**ISSUES**](https://github.com/codemirror/dev/issues) | [**FORUM**](https://discuss.codemirror.net/c/v6/) | [**CHANGELOG**](https://github.com/codemirror/lsp-client/blob/main/CHANGELOG.md) ] 6 | 7 | This package implements a language server protocol (LSP) client for 8 | the [CodeMirror](https://codemirror.net/) code editor. 9 | 10 | The [project page](https://codemirror.net/) has more information, a 11 | number of [examples](https://codemirror.net/examples/) and the 12 | [documentation](https://codemirror.net/docs/). 13 | 14 | Note that this code **does not** have a license yet. That should soon 15 | change. 16 | 17 | We aim to be an inclusive, welcoming community. To make that explicit, 18 | we have a [code of 19 | conduct](http://contributor-covenant.org/version/1/1/0/) that applies 20 | to communication around the project. 21 | 22 | ## Usage 23 | 24 | There are various ways to run a language server and connect it to a 25 | web page. You can run it on the server and proxy it through a web 26 | socket, or, if it is written in JavaScript or can be compiled to WASM, 27 | run it directly in the client. The @codemirror/lsp-client package 28 | talks to the server through a ([`Transport`](#lsp-client.Transport)) 29 | object, which exposes a small interface for sending and receiving JSON 30 | messages. 31 | 32 | Responsibility for how to actually talk to the server, how to connect 33 | and to handle disconnects are left to the code that implements the 34 | transport. 35 | 36 | This example uses a crude transport that doesn't handle errors at all. 37 | 38 | 39 | ```javascript 40 | import {Transport, LSPClient, languageServerSupport} from "@codemirror/lsp-client" 41 | import {basicSetup, EditorView} from "codemirror" 42 | import {typescriptLanguage} from "@codemirror/lang-javascript" 43 | 44 | function simpleWebSocketTransport(uri: string): Promise { 45 | let handlers: ((value: string) => void)[] = [] 46 | let sock = new WebSocket(uri) 47 | sock.onmessage = e => { for (let h of handlers) h(e.data.toString()) } 48 | return new Promise(resolve => { 49 | sock.onopen = () => resolve({ 50 | send(message: string) { sock.send(message) }, 51 | subscribe(handler: (value: string) => void) { handlers.push(handler) }, 52 | unsubscribe(handler: (value: string) => void) { handlers = handlers.filter(h => h != handler) } 53 | }) 54 | }) 55 | } 56 | 57 | let transport = await simpleWebSocketTransport("ws://host:port") 58 | let client = new LSPClient().connect(transport) 59 | 60 | new EditorView({ 61 | extensions: [ 62 | basicSetup, 63 | typescriptLanguage, 64 | languageServerSupport(client, "file:///some/file.ts"), 65 | ], 66 | parent: document.body 67 | }) 68 | ``` 69 | 70 | ## API Reference 71 | 72 | ### Client 73 | 74 |
75 |
76 |

77 | class 78 | LSPClient

79 |
80 | 81 |

An LSP client manages a connection to a language server. It should 82 | be explicitly connected before 83 | use.

84 |
85 | new LSPClient(config⁠?: LSPClientConfig = {})
86 | 87 |

Create a client object.

88 |
89 | workspace: Workspace
90 | 91 |

The client's workspace.

92 |
93 | serverCapabilities: ServerCapabilities | null
94 | 95 |

The capabilities advertised by the server. Will be null when not 96 | connected or initialized.

97 |
98 | initializing: Promise<null>
99 | 100 |

A promise that resolves once the client connection is initialized. Will be 101 | replaced by a new promise object when you call disconnect.

102 |
103 | connected: boolean
104 | 105 |

Whether this client is connected (has a transport).

106 |
107 | connect(transportTransport) → LSPClient
108 | 109 |

Connect this client to a server over the given transport. Will 110 | immediately start the initialization exchange with the server, 111 | and resolve this.initializing (which it also returns) when 112 | successful.

113 |
114 | disconnect()
115 | 116 |

Disconnect the client from the server.

117 |
118 | didOpen(fileWorkspaceFile)
119 | 120 |

Send a textDocument/didOpen notification to the server.

121 |
122 | didClose(uristring)
123 | 124 |

Send a textDocument/didClose notification to the server.

125 |
126 | request<Params, Result>(methodstring, paramsParams) → Promise<Result>
127 | 128 |

Make a request to the server. Returns a promise that resolves to 129 | the response or rejects with a failure message. You'll probably 130 | want to use types from the vscode-languageserver-protocol 131 | package for the type parameters.

132 |

The caller is responsible for 133 | synchronizing state before the 134 | request and correctly handling state drift caused by local 135 | changes that happend during the request.

136 |
137 | notification<Params>(methodstring, paramsParams)
138 | 139 |

Send a notification to the server.

140 |
141 | cancelRequest(params: any)
142 | 143 |

Cancel the in-progress request with the given parameter value 144 | (which is compared by identity).

145 |
146 | workspaceMapping() → WorkspaceMapping
147 | 148 |

Create a workspace mapping that 149 | tracks changes to files in this client's workspace, relative to 150 | the moment where it was created. Make sure you call 151 | destroy on the mapping 152 | when you're done with it.

153 |
154 | withMapping<T>(f: fn(mappingWorkspaceMapping) → Promise<T>) → Promise<T>
155 | 156 |

Run the given promise with a workspace 157 | mapping active. Automatically 158 | release the mapping when the promise resolves or rejects.

159 |
160 | sync()
161 | 162 |

Push any pending changes in 163 | the open files to the server. You'll want to call this before 164 | most types of requests, to make sure the server isn't working 165 | with outdated information.

166 |
167 | 168 |
169 |
170 |

171 | type 172 | LSPClientConfig

173 |
174 | 175 |

Configuration options that can be passed to the LSP client.

176 |
177 | rootUri⁠?: string
178 | 179 |

The project root URI passed to the server, when necessary.

180 |
181 | workspace⁠?: fn(clientLSPClient) → Workspace
182 | 183 |

An optional function to create a 184 | workspace object for the client to use. 185 | When not given, this will default to a simple workspace that 186 | only opens files that have an active editor, and only allows one 187 | editor per file.

188 |
189 | timeout⁠?: number
190 | 191 |

The amount of milliseconds after which requests are 192 | automatically timed out. Defaults to 3000.

193 |
194 | sanitizeHTML⁠?: fn(htmlstring) → string
195 | 196 |

LSP servers can send Markdown code, which the client must render 197 | and display as HTML. Markdown can contain arbitrary HTML and is 198 | thus a potential channel for cross-site scripting attacks, if 199 | someone is able to compromise your LSP server or your connection 200 | to it. You can pass an HTML sanitizer here to strip out 201 | suspicious HTML structure.

202 |
203 | highlightLanguage⁠?: fn(namestring) → Language | null
204 | 205 |

By default, the Markdown renderer will only be able to highlght 206 | code embedded in the Markdown text when its language tag matches 207 | the name of the language used by the editor. You can provide a 208 | function here that returns a CodeMirror language object for a 209 | given language tag to support more languages.

210 |
211 | notificationHandlers⁠?: Object<fn(clientLSPClient, params: any) → boolean>
212 | 213 |

By default, the client will only handle the server notifications 214 | window/logMessage (logging warnings and errors to the console) 215 | and window/showMessage. You can pass additional handlers here. 216 | They will be tried before the built-in handlers, and override 217 | those when they return true.

218 |
219 | unhandledNotification⁠?: fn(clientLSPClient, methodstring, params: any)
220 | 221 |

When no handler is found for a notification, it will be passed 222 | to this function, if given.

223 |
224 | 225 |
226 |
227 |

228 | type 229 | Transport

230 |
231 | 232 |

An object of this type should be used to wrap whatever transport 233 | layer you use to talk to your language server. Messages should 234 | contain only the JSON messages, no LSP headers.

235 |
236 | send(messagestring)
237 | 238 |

Send a message to the server. Should throw if the connection is 239 | broken somehow.

240 |
241 | subscribe(handler: fn(valuestring))
242 | 243 |

Register a handler for messages coming from the server.

244 |
245 | unsubscribe(handler: fn(valuestring))
246 | 247 |

Unregister a handler registered with subscribe.

248 |
249 | 250 |
251 |
252 |

253 | class 254 | LSPPlugin

255 |
256 | 257 |

A plugin that connects a given editor to a language server client.

258 |
259 | client: LSPClient
260 | 261 |

The client connection.

262 |
263 | uri: string
264 | 265 |

The URI of this file.

266 |
267 | view: EditorView
268 | 269 |

The editor view that this plugin belongs to.

270 |
271 | docToHTML(valuestring | MarkupContent, defaultKind⁠?: MarkupKind = "plaintext") → string
272 | 273 |

Render a doc string from the server to HTML.

274 |
275 | toPosition(posnumber, doc⁠?: Text = this.view.state.doc) → Position
276 | 277 |

Convert a CodeMirror document offset into an LSP {line, character} object. Defaults to using the view's current 278 | document, but can be given another one.

279 |
280 | fromPosition(posPosition, doc⁠?: Text = this.view.state.doc) → number
281 | 282 |

Convert an LSP {line, character} object to a CodeMirror 283 | document offset.

284 |
285 | reportError(messagestring, err: any)
286 | 287 |

Display an error in this plugin's editor.

288 |
289 | unsyncedChanges: ChangeSet
290 | 291 |

The changes accumulated in this editor that have not been sent 292 | to the server yet.

293 |
294 | clear()
295 | 296 |

Reset the unsynced 297 | changes. Should probably 298 | only be called by a workspace.

299 |
300 | static get(viewEditorView) → LSPPlugin | null
301 | 302 |

Get the LSP plugin associated with an editor, if any.

303 |
304 | static create(clientLSPClient, fileURIstring, languageID⁠?: string) → Extension
305 | 306 |

Create an editor extension that connects that editor to the 307 | given LSP client. This extension is necessary to use LSP-related 308 | functionality exported by this package. Creating an editor with 309 | this plugin will cause 310 | openFile to be called on the 311 | workspace.

312 |

By default, the language ID given to the server for this file is 313 | derived from the editor's language configuration via 314 | Language.name. You can pass in 315 | a specific ID as a third parameter.

316 |
317 | 318 |
319 |
320 |

321 | class 322 | WorkspaceMapping

323 |
324 | 325 |

A workspace mapping is used to track changes made to open 326 | documents, so that positions returned by a request can be 327 | interpreted in terms of the current, potentially changed document.

328 |
329 | getMapping(uristring) → ChangeDesc | null
330 | 331 |

Get the changes made to the document with the given URI since 332 | the mapping was created. Returns null for documents that aren't 333 | open.

334 |
335 | mapPos(uristring, posnumber, assoc⁠?: number) → number
336 | 337 |

Map a position in the given file forward to the current document state.

338 |
339 | mapPosition(uristring, posPosition, assoc⁠?: number) → number
340 | 341 |

Convert an LSP-style position referring to a document at the 342 | time the mapping was created to an offset in the current document.

343 |
344 | destroy()
345 | 346 |

Disconnect this mapping from the client so that it will no 347 | longer be notified of new changes. You must make sure to call 348 | this on every mapping you create, except when you use 349 | withMapping, which will 350 | automatically schedule a disconnect when the given promise 351 | resolves.

352 |
353 | 354 |
355 |
356 |

Workspaces

357 |
358 |
359 |

360 | abstract class 361 | Workspace

362 |
363 | 364 |

Implementing your own workspace class can provide more control 365 | over the way files are loaded and managed when interacting with 366 | the language server. See 367 | LSPClientConfig.workspace.

368 |
369 | new Workspace(clientLSPClient)
370 | 371 |

The constructor, as called by the client when creating a 372 | workspace.

373 |
374 | abstract files: WorkspaceFile[]
375 | 376 |

The files currently open in the workspace.

377 |
378 | client: LSPClient
379 | 380 |

The LSP client associated with this workspace.

381 |
382 | getFile(uristring) → WorkspaceFile | null
383 | 384 |

Find the open file with the given URI, if it exists. The default 385 | implementation just looks it up in this.files.

386 |
387 | abstract syncFiles() → readonly {file: WorkspaceFile, prevDoc: Text, changes: ChangeSet}[]
388 | 389 |

Check all open files for changes (usually from editors, but they 390 | may also come from other sources). When a file is changed, 391 | return a record that describes the changes, and update the file's 392 | version and 393 | doc properties to reflect the 394 | new version.

395 |
396 | requestFile(uristring) → Promise<WorkspaceFile | null>
397 | 398 |

Called to request that the workspace open a file. The default 399 | implementation simply returns the file if it is open, null 400 | otherwise.

401 |
402 | abstract openFile(uristring, languageIdstring, viewEditorView)
403 | 404 |

Called when an editor is created for a file. The implementation 405 | should track the file in 406 | this.files and, if it wasn't 407 | open already, call 408 | LSPClient.didOpen.

409 |
410 | abstract closeFile(uristring, viewEditorView)
411 | 412 |

Called when an editor holding this file is destroyed or 413 | reconfigured to no longer hold it. The implementation should 414 | track this and, when it closes the file, make sure to call 415 | LSPClient.didOpen.

416 |
417 | connected()
418 | 419 |

Called when the client for this workspace is connected. The 420 | default implementation calls 421 | LSPClient.didOpen on all open 422 | files.

423 |
424 | disconnected()
425 | 426 |

Called when the client for this workspace is disconnected. The 427 | default implementation does nothing.

428 |
429 | updateFile(uristring, updateTransactionSpec)
430 | 431 |

Called when a server-initiated change to a file is applied. The 432 | default implementation simply dispatches the update to the 433 | file's view, if the file is open and has a view.

434 |
435 | displayFile(uristring) → Promise<EditorView | null>
436 | 437 |

When the client needs to put a file other than the one loaded in 438 | the current editor in front of the user, for example in 439 | jumpToDefinition, it will call 440 | this function. It should make sure to create or find an editor 441 | with the file and make it visible to the user, or return null if 442 | this isn't possible.

443 |
444 | 445 |
446 |
447 |

448 | interface 449 | WorkspaceFile

450 |
451 | 452 |

A file that is open in a workspace.

453 |
454 | uri: string
455 | 456 |

The file's unique URI.

457 |
458 | languageId: string
459 | 460 |

The LSP language ID for the file's content.

461 |
462 | version: number
463 | 464 |

The current version of the file.

465 |
466 | doc: Text
467 | 468 |

The document corresponding to this.version. Will not reflect 469 | changes made after that version was synchronized. Will be 470 | updated, along with version, by 471 | syncFiles.

472 |
473 | getView(main⁠?: EditorView) → EditorView | null
474 | 475 |

Get an active editor view for this file, if there is one. For 476 | workspaces that support multiple views on a file, main 477 | indicates a preferred view.

478 |
479 | 480 |
481 |
482 |

Extensions

483 |
484 |
485 | languageServerSupport(clientLSPClient, uristring, languageID⁠?: string) → Extension
486 | 487 |

Returns an extension that enables the LSP 488 | plugin and all other features provided by 489 | this package. You can also pick and choose individual extensions 490 | from the exports. In that case, make sure to also include 491 | LSPPlugin.create in your 492 | extensions, or the others will not work.

493 |
494 |
495 | serverCompletion(config⁠?: Object = {}) → Extension
496 | 497 |

Register the language server completion 498 | source as an autocompletion 499 | source.

500 |
501 | config
502 | 503 |
504 | override⁠?: boolean
505 | 506 |

By default, the completion source that asks the language server 507 | for completions is added as a regular source, in addition to any 508 | other sources. Set this to true to make it replace all 509 | completion sources.

510 |
511 |
512 | serverCompletionSource: CompletionSource
513 | 514 |

A completion source that requests completions from a language 515 | server.

516 |
517 |
518 | hoverTooltips(config⁠?: {hoverTime⁠?: number} = {}) → Extension
519 | 520 |

Create an extension that queries the language server for hover 521 | tooltips when the user hovers over the code with their pointer, 522 | and displays a tooltip when the server provides one.

523 |
524 |
525 | formatDocument: Command
526 | 527 |

This command asks the language server to reformat the document, 528 | and then applies the changes it returns.

529 |
530 |
531 | formatKeymap: readonly KeyBinding[]
532 | 533 |

A keymap that binds Shift-Alt-f to 534 | formatDocument.

535 |
536 |
537 | renameSymbol: Command
538 | 539 |

This command will, if the cursor is over a word, prompt the user 540 | for a new name for that symbol, and ask the language server to 541 | perform a rename of that symbol.

542 |

Note that this may affect files other than the one loaded into 543 | this view. See the 544 | Workspace.updateFile 545 | method.

546 |
547 |
548 | renameKeymap: readonly KeyBinding[]
549 | 550 |

A keymap that binds F2 to renameSymbol.

551 |
552 |
553 | signatureHelp(config⁠?: {keymap⁠?: boolean} = {}) → Extension
554 | 555 |

Returns an extension that enables signature help. Will bind the 556 | keys in signatureKeymap unless 557 | keymap is set to false.

558 |
559 |
560 | showSignatureHelp: Command
561 | 562 |

Explicitly prompt the server to provide signature help at the 563 | cursor.

564 |
565 |
566 | nextSignature: Command
567 | 568 |

If there is an active signature tooltip with multiple signatures, 569 | move to the next one.

570 |
571 |
572 | prevSignature: Command
573 | 574 |

If there is an active signature tooltip with multiple signatures, 575 | move to the previous signature.

576 |
577 |
578 | signatureKeymap: readonly KeyBinding[]
579 | 580 |

A keymap that binds

581 |
    582 |
  • 583 |

    Ctrl-Shift-Space (Cmd-Shift-Space on macOS) to 584 | showSignatureHelp

    585 |
  • 586 |
  • 587 |

    Ctrl-Shift-ArrowUp (Cmd-Shift-ArrowUp on macOS) to 588 | prevSignature

    589 |
  • 590 |
  • 591 |

    Ctrl-Shift-ArrowDown (Cmd-Shift-ArrowDown on macOS) to 592 | nextSignature

    593 |
  • 594 |
595 |

Note that these keys are automatically bound by 596 | signatureHelp unless you pass it 597 | keymap: false.

598 |
599 |
600 | jumpToDefinition: Command
601 | 602 |

Jump to the definition of the symbol at the cursor. To support 603 | cross-file jumps, you'll need to implement 604 | Workspace.displayFile.

605 |
606 |
607 | jumpToDeclaration: Command
608 | 609 |

Jump to the declaration of the symbol at the cursor.

610 |
611 |
612 | jumpToTypeDefinition: Command
613 | 614 |

Jump to the type definition of the symbol at the cursor.

615 |
616 |
617 | jumpToImplementation: Command
618 | 619 |

Jump to the implementation of the symbol at the cursor.

620 |
621 |
622 | jumpToDefinitionKeymap: readonly KeyBinding[]
623 | 624 |

Binds F12 to jumpToDefinition.

625 |
626 |
627 | findReferences: Command
628 | 629 |

Ask the server to locate all references to the symbol at the 630 | cursor. When the server can provide such references, show them as 631 | a list in a panel.

632 |
633 |
634 | closeReferencePanel: Command
635 | 636 |

Close the reference panel, if it is open.

637 |
638 |
639 | findReferencesKeymap: readonly KeyBinding[]
640 | 641 |

Binds Shift-F12 to findReferences 642 | and Escape to 643 | closeReferencePanel.

644 |
645 |
646 | -------------------------------------------------------------------------------- /bin/tsserver-ws.js: -------------------------------------------------------------------------------- 1 | import {WebSocketServer} from "ws" 2 | import {spawn} from "node:child_process" 3 | import {join} from "node:path" 4 | 5 | let project = join(import.meta.dirname, "..", "test") 6 | 7 | let server = new WebSocketServer({port: 8777}) 8 | 9 | class MessageReader { 10 | message = "" 11 | pending = -1 12 | 13 | constructor(onMessage) { this.onMessage = onMessage } 14 | 15 | data(data) { 16 | this.message += data 17 | console.log("GET ", data) 18 | for (;;) { 19 | if (this.pending == -1) { 20 | let brk = this.message.indexOf("\r\n\r\n") 21 | if (brk < 0) break 22 | let len = /content-length: (\d+)/i.exec(this.message.slice(0, brk)) 23 | if (!len) throw new Error("Missing content-length header") 24 | this.message = this.message.slice(brk + 4) 25 | this.pending = +len[1] 26 | console.log("parsed header", this.pending, "msg=", this.message) 27 | } else if (this.pending <= this.message.length) { 28 | console.log('finished', this.pending) 29 | this.onMessage(this.message.slice(0, this.pending)) 30 | this.message = this.message.slice(this.pending) 31 | this.pending = -1 32 | } else { 33 | console.log("out") 34 | break 35 | } 36 | } 37 | } 38 | } 39 | 40 | server.on("connection", sock => { 41 | console.log("New connection") 42 | let ts = spawn(join(import.meta.dirname, "..", "..", "node_modules", ".bin", "typescript-language-server"), ["--stdio"], { 43 | cwd: project, 44 | encoding: "utf8", 45 | stdio: ["pipe", "pipe", process.stderr] 46 | }) 47 | let reader = new MessageReader(message => { 48 | console.log("==> " + message) 49 | sock.send(message) 50 | }) 51 | ts.stdout.on("data", blob => reader.data(blob.toString("utf8"))) 52 | ts.on("close", () => { 53 | sock.close() 54 | }) 55 | sock.on("error", console.error) 56 | sock.on("message", data => { 57 | console.log("<== " + data) 58 | ts.stdin.write(`Content-Length: ${data.length}\r\n\r\n${data}`) 59 | }) 60 | sock.on("close", () => { 61 | console.log("Closed") 62 | ts.kill() 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@codemirror/lsp-client", 3 | "version": "0.0.1", 4 | "description": "Language server protocol client for CodeMirror", 5 | "keywords": [ 6 | "codemirror", 7 | "lsp" 8 | ], 9 | "license": "MIT", 10 | "author": "Marijn Haverbeke ", 11 | "type": "module", 12 | "main": "dist/index.js", 13 | "scripts": { 14 | "test": "cm-runtests", 15 | "prepare": "cm-buildhelper src/index.ts" 16 | }, 17 | "dependencies": { 18 | "marked": "^15.0.12", 19 | "@codemirror/autocomplete": "^6.18.6", 20 | "@codemirror/language": "^6.11.0", 21 | "@codemirror/state": "^6.5.2", 22 | "@codemirror/view": "^6.37.0", 23 | "@lezer/highlight": "^1.2.1", 24 | "vscode-languageserver-protocol": "^3.17.5" 25 | }, 26 | "devDependencies": { 27 | "@codemirror/buildhelper": "^1.0.0", 28 | "@codemirror/lang-javascript": "^1.0.0", 29 | "typescript": "^5.8.3", 30 | "typescript-language-server": "^4.3.4" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # @codemirror/lsp-client [![NPM version](https://img.shields.io/npm/v/@codemirror/lsp-client.svg)](https://www.npmjs.org/package/@codemirror/lsp-client) 4 | 5 | [ [**WEBSITE**](https://codemirror.net/) | [**ISSUES**](https://github.com/codemirror/dev/issues) | [**FORUM**](https://discuss.codemirror.net/c/v6/) | [**CHANGELOG**](https://github.com/codemirror/lsp-client/blob/main/CHANGELOG.md) ] 6 | 7 | This package implements a language server protocol (LSP) client for 8 | the [CodeMirror](https://codemirror.net/) code editor. 9 | 10 | The [project page](https://codemirror.net/) has more information, a 11 | number of [examples](https://codemirror.net/examples/) and the 12 | [documentation](https://codemirror.net/docs/). 13 | 14 | Note that this code **does not** have a license yet. That should soon 15 | change. 16 | 17 | We aim to be an inclusive, welcoming community. To make that explicit, 18 | we have a [code of 19 | conduct](http://contributor-covenant.org/version/1/1/0/) that applies 20 | to communication around the project. 21 | 22 | ## Usage 23 | 24 | There are various ways to run a language server and connect it to a 25 | web page. You can run it on the server and proxy it through a web 26 | socket, or, if it is written in JavaScript or can be compiled to WASM, 27 | run it directly in the client. The @codemirror/lsp-client package 28 | talks to the server through a ([`Transport`](#lsp-client.Transport)) 29 | object, which exposes a small interface for sending and receiving JSON 30 | messages. 31 | 32 | Responsibility for how to actually talk to the server, how to connect 33 | and to handle disconnects are left to the code that implements the 34 | transport. 35 | 36 | This example uses a crude transport that doesn't handle errors at all. 37 | 38 | 39 | ```javascript 40 | import {Transport, LSPClient, languageServerSupport} from "@codemirror/lsp-client" 41 | import {basicSetup, EditorView} from "codemirror" 42 | import {typescriptLanguage} from "@codemirror/lang-javascript" 43 | 44 | function simpleWebSocketTransport(uri: string): Promise { 45 | let handlers: ((value: string) => void)[] = [] 46 | let sock = new WebSocket(uri) 47 | sock.onmessage = e => { for (let h of handlers) h(e.data.toString()) } 48 | return new Promise(resolve => { 49 | sock.onopen = () => resolve({ 50 | send(message: string) { sock.send(message) }, 51 | subscribe(handler: (value: string) => void) { handlers.push(handler) }, 52 | unsubscribe(handler: (value: string) => void) { handlers = handlers.filter(h => h != handler) } 53 | }) 54 | }) 55 | } 56 | 57 | let transport = await simpleWebSocketTransport("ws://host:port") 58 | let client = new LSPClient().connect(transport) 59 | 60 | new EditorView({ 61 | extensions: [ 62 | basicSetup, 63 | typescriptLanguage, 64 | languageServerSupport(client, "file:///some/file.ts"), 65 | ], 66 | parent: document.body 67 | }) 68 | ``` 69 | 70 | ## API Reference 71 | 72 | ### Client 73 | 74 | @LSPClient 75 | 76 | @LSPClientConfig 77 | 78 | @Transport 79 | 80 | @LSPPlugin 81 | 82 | @WorkspaceMapping 83 | 84 | ### Workspaces 85 | 86 | @Workspace 87 | 88 | @WorkspaceFile 89 | 90 | ### Extensions 91 | 92 | @languageServerSupport 93 | 94 | @serverCompletion 95 | 96 | @serverCompletionSource 97 | 98 | @hoverTooltips 99 | 100 | @formatDocument 101 | 102 | @formatKeymap 103 | 104 | @renameSymbol 105 | 106 | @renameKeymap 107 | 108 | @signatureHelp 109 | 110 | @showSignatureHelp 111 | 112 | @nextSignature 113 | 114 | @prevSignature 115 | 116 | @signatureKeymap 117 | 118 | @jumpToDefinition 119 | 120 | @jumpToDeclaration 121 | 122 | @jumpToTypeDefinition 123 | 124 | @jumpToImplementation 125 | 126 | @jumpToDefinitionKeymap 127 | 128 | @findReferences 129 | 130 | @closeReferencePanel 131 | 132 | @findReferencesKeymap 133 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import type * as lsp from "vscode-languageserver-protocol" 2 | import {showDialog} from "@codemirror/view" 3 | import {ChangeSet, ChangeDesc, MapMode, Text} from "@codemirror/state" 4 | import {Language} from "@codemirror/language" 5 | import {LSPPlugin} from "./plugin" 6 | import {toPosition, fromPosition} from "./pos" 7 | import {Workspace, WorkspaceFile, DefaultWorkspace} from "./workspace" 8 | 9 | class Request { 10 | declare resolve: (result: Result) => void 11 | declare reject: (error: any) => void 12 | promise: Promise 13 | 14 | constructor( 15 | readonly id: number, 16 | readonly params: any, 17 | readonly timeout: number 18 | ) { 19 | this.promise = new Promise((resolve, reject) => { 20 | this.resolve = resolve 21 | this.reject = reject 22 | }) 23 | } 24 | } 25 | 26 | const clientCapabilities: lsp.ClientCapabilities = { 27 | general: { 28 | markdown: { 29 | parser: "marked", 30 | }, 31 | }, 32 | textDocument: { 33 | completion: { 34 | completionItem: { 35 | snippetSupport: true, 36 | documentationFormat: ["plaintext", "markdown"], 37 | insertReplaceSupport: false, 38 | }, 39 | completionList: { 40 | itemDefaults: ["commitCharacters", "editRange", "insertTextFormat"] 41 | }, 42 | completionItemKind: {valueSet: []}, 43 | contextSupport: true, 44 | }, 45 | hover: { 46 | contentFormat: ["markdown", "plaintext"] 47 | }, 48 | formatting: {}, 49 | rename: {}, 50 | signatureHelp: { 51 | contextSupport: true, 52 | signatureInformation: { 53 | documentationFormat: ["markdown", "plaintext"], 54 | parameterInformation: {labelOffsetSupport: true}, 55 | activeParameterSupport: true, 56 | }, 57 | }, 58 | definition: {}, 59 | declaration: {}, 60 | implementation: {}, 61 | typeDefinition: {}, 62 | references: {}, 63 | }, 64 | } 65 | 66 | /// A workspace mapping is used to track changes made to open 67 | /// documents, so that positions returned by a request can be 68 | /// interpreted in terms of the current, potentially changed document. 69 | export class WorkspaceMapping { 70 | /// @internal 71 | mappings: Map = new Map 72 | private startDocs: Map = new Map 73 | 74 | /// @internal 75 | constructor(private client: LSPClient) { 76 | for (let file of client.workspace.files) { 77 | this.mappings.set(file.uri, ChangeSet.empty(file.doc.length)) 78 | this.startDocs.set(file.uri, file.doc) 79 | } 80 | } 81 | 82 | /// @internal 83 | addChanges(uri: string, changes: ChangeDesc) { 84 | let known = this.mappings.get(uri) 85 | if (known) this.mappings.set(uri, known.composeDesc(changes)) 86 | } 87 | 88 | /// Get the changes made to the document with the given URI since 89 | /// the mapping was created. Returns null for documents that aren't 90 | /// open. 91 | getMapping(uri: string) { 92 | let known = this.mappings.get(uri) 93 | if (!known) return null 94 | let file = this.client.workspace.getFile(uri), view = file?.getView(), plugin = view && LSPPlugin.get(view) 95 | return plugin ? known.composeDesc(plugin.unsyncedChanges) : known 96 | } 97 | 98 | /// Map a position in the given file forward to the current document state. 99 | mapPos(uri: string, pos: number, assoc?: number): number 100 | mapPos(uri: string, pos: number, assoc: number, mode: MapMode): number | null 101 | mapPos(uri: string, pos: number, assoc = -1, mode: MapMode = MapMode.Simple): number | null { 102 | let changes = this.getMapping(uri) 103 | return changes ? changes.mapPos(pos, assoc, mode) : pos 104 | } 105 | 106 | /// Convert an LSP-style position referring to a document at the 107 | /// time the mapping was created to an offset in the current document. 108 | mapPosition(uri: string, pos: lsp.Position, assoc?: number): number 109 | mapPosition(uri: string, pos: lsp.Position, assoc: number, mode: MapMode): number | null 110 | mapPosition(uri: string, pos: lsp.Position, assoc = -1, mode: MapMode = MapMode.Simple): number | null { 111 | let start = this.startDocs.get(uri) 112 | if (!start) throw new Error("Cannot map from a file that's not in the workspace") 113 | let off = fromPosition(start, pos) 114 | let changes = this.getMapping(uri) 115 | return changes ? changes.mapPos(off, assoc, mode) : off 116 | } 117 | 118 | /// Disconnect this mapping from the client so that it will no 119 | /// longer be notified of new changes. You must make sure to call 120 | /// this on every mapping you create, except when you use 121 | /// [`withMapping`](#lsp-client.LSPClient.withMapping), which will 122 | /// automatically schedule a disconnect when the given promise 123 | /// resolves. 124 | destroy() { 125 | this.client.activeMappings = this.client.activeMappings.filter(m => m != this) 126 | } 127 | } 128 | 129 | /// An object of this type should be used to wrap whatever transport 130 | /// layer you use to talk to your language server. Messages should 131 | /// contain only the JSON messages, no LSP headers. 132 | export type Transport = { 133 | /// Send a message to the server. Should throw if the connection is 134 | /// broken somehow. 135 | send(message: string): void 136 | /// Register a handler for messages coming from the server. 137 | subscribe(handler: (value: string) => void): void 138 | /// Unregister a handler registered with `subscribe`. 139 | unsubscribe(handler: (value: string) => void): void 140 | } 141 | 142 | const defaultNotificationHandlers: {[method: string]: (client: LSPClient, params: any) => void} = { 143 | "window/logMessage": (client, params: lsp.LogMessageParams) => { 144 | if (params.type == 1) console.error("[lsp] " + params.message) 145 | else if (params.type == 2) console.warn("[lsp] " + params.message) 146 | }, 147 | "window/showMessage": (client, params: lsp.ShowMessageParams) => { 148 | if (params.type > 3 /* Info */) return 149 | let view 150 | for (let f of client.workspace.files) if (view = f.getView()) break 151 | if (view) showDialog(view, { 152 | label: params.message, 153 | class: "cm-lsp-message cm-lsp-message-" + (params.type == 1 ? "error" : params.type == 2 ? "warning" : "info"), 154 | top: true 155 | }) 156 | } 157 | } 158 | 159 | /// Configuration options that can be passed to the LSP client. 160 | export type LSPClientConfig = { 161 | /// The project root URI passed to the server, when necessary. 162 | rootUri?: string 163 | /// An optional function to create a 164 | /// [workspace](#lsp-client.Workspace) object for the client to use. 165 | /// When not given, this will default to a simple workspace that 166 | /// only opens files that have an active editor, and only allows one 167 | /// editor per file. 168 | workspace?: (client: LSPClient) => Workspace 169 | /// The amount of milliseconds after which requests are 170 | /// automatically timed out. Defaults to 3000. 171 | timeout?: number 172 | /// LSP servers can send Markdown code, which the client must render 173 | /// and display as HTML. Markdown can contain arbitrary HTML and is 174 | /// thus a potential channel for cross-site scripting attacks, if 175 | /// someone is able to compromise your LSP server or your connection 176 | /// to it. You can pass an HTML sanitizer here to strip out 177 | /// suspicious HTML structure. 178 | sanitizeHTML?: (html: string) => string 179 | /// By default, the Markdown renderer will only be able to highlght 180 | /// code embedded in the Markdown text when its language tag matches 181 | /// the name of the language used by the editor. You can provide a 182 | /// function here that returns a CodeMirror language object for a 183 | /// given language tag to support more languages. 184 | highlightLanguage?: (name: string) => Language | null 185 | /// By default, the client will only handle the server notifications 186 | /// `window/logMessage` (logging warnings and errors to the console) 187 | /// and `window/showMessage`. You can pass additional handlers here. 188 | /// They will be tried before the built-in handlers, and override 189 | /// those when they return true. 190 | notificationHandlers?: {[method: string]: (client: LSPClient, params: any) => boolean} 191 | /// When no handler is found for a notification, it will be passed 192 | /// to this function, if given. 193 | unhandledNotification?: (client: LSPClient, method: string, params: any) => void 194 | } 195 | 196 | /// An LSP client manages a connection to a language server. It should 197 | /// be explicitly [connected](#lsp-client.LSPClient.connect) before 198 | /// use. 199 | export class LSPClient { 200 | /// @internal 201 | transport: Transport | null = null 202 | /// The client's [workspace](#lsp-client.Workspace). 203 | workspace: Workspace 204 | private nextReqID = 0 205 | private requests: Request[] = [] 206 | /// @internal 207 | activeMappings: WorkspaceMapping[] = [] 208 | /// The capabilities advertised by the server. Will be null when not 209 | /// connected or initialized. 210 | serverCapabilities: lsp.ServerCapabilities | null = null 211 | private supportSync = -1 212 | /// A promise that resolves once the client connection is initialized. Will be 213 | /// replaced by a new promise object when you call `disconnect`. 214 | initializing: Promise 215 | declare private init: {resolve: (value: null) => void, reject: (err: any) => void} 216 | private timeout: number 217 | 218 | /// Create a client object. 219 | constructor( 220 | /// @internal 221 | readonly config: LSPClientConfig = {} 222 | ) { 223 | this.receiveMessage = this.receiveMessage.bind(this) 224 | this.initializing = new Promise((resolve, reject) => this.init = {resolve, reject}) 225 | this.timeout = config.timeout ?? 3000 226 | this.workspace = config.workspace ? config.workspace(this) : new DefaultWorkspace(this) 227 | } 228 | 229 | /// Whether this client is connected (has a transport). 230 | get connected() { return !!this.transport } 231 | 232 | /// Connect this client to a server over the given transport. Will 233 | /// immediately start the initialization exchange with the server, 234 | /// and resolve `this.initializing` (which it also returns) when 235 | /// successful. 236 | connect(transport: Transport) { 237 | if (this.transport) this.transport.unsubscribe(this.receiveMessage) 238 | this.transport = transport 239 | transport.subscribe(this.receiveMessage) 240 | this.requestInner("initialize", { 241 | processId: null, 242 | clientInfo: {name: "@codemirror/lsp-client"}, 243 | rootUri: this.config.rootUri || null, 244 | capabilities: clientCapabilities 245 | }).promise.then(resp => { 246 | this.serverCapabilities = resp.capabilities 247 | let sync = resp.capabilities.textDocumentSync 248 | this.supportSync = sync == null ? 0 : typeof sync == "number" ? sync : sync.change ?? 0 249 | transport.send(JSON.stringify({jsonrpc: "2.0", method: "initialized", params: {}})) 250 | this.init.resolve(null) 251 | }, this.init.reject) 252 | this.workspace.connected() 253 | return this 254 | } 255 | 256 | /// Disconnect the client from the server. 257 | disconnect() { 258 | if (this.transport) this.transport.unsubscribe(this.receiveMessage) 259 | this.serverCapabilities = null 260 | this.initializing = new Promise((resolve, reject) => this.init = {resolve, reject}) 261 | this.workspace.disconnected() 262 | } 263 | 264 | /// Send a `textDocument/didOpen` notification to the server. 265 | didOpen(file: WorkspaceFile) { 266 | this.notification("textDocument/didOpen", { 267 | textDocument: { 268 | uri: file.uri, 269 | languageId: file.languageId, 270 | text: file.doc.toString(), 271 | version: file.version 272 | } 273 | }) 274 | } 275 | 276 | /// Send a `textDocument/didClose` notification to the server. 277 | didClose(uri: string) { 278 | this.notification("textDocument/didClose", {textDocument: {uri}}) 279 | } 280 | 281 | private receiveMessage(msg: string) { 282 | const value = JSON.parse(msg) as lsp.ResponseMessage | lsp.NotificationMessage | lsp.RequestMessage 283 | if ("id" in value && !("method" in value)) { 284 | let index = this.requests.findIndex(r => r.id == value.id) 285 | if (index < 0) { 286 | console.warn(`[lsp] Received a response for non-existent request ${value.id}`) 287 | } else { 288 | let req = this.requests[index] 289 | clearTimeout(req.timeout) 290 | this.requests.splice(index, 1) 291 | if (value.error) req.reject(value.error) 292 | else req.resolve(value.result) 293 | } 294 | } else if (!("id" in value)) { 295 | let handler = this.config.notificationHandlers?.[value.method] 296 | if (handler && handler(this, value.params)) return 297 | let deflt = defaultNotificationHandlers[value.method] 298 | if (deflt) deflt(this, value.params) 299 | else if (this.config.unhandledNotification) this.config.unhandledNotification(this, value.method, value.params) 300 | } else { 301 | let resp: lsp.ResponseMessage = { 302 | jsonrpc: "2.0", 303 | id: value.id, 304 | error: {code: -32601 /* MethodNotFound */, message: "Method not implemented"} 305 | } 306 | this.transport!.send(JSON.stringify(resp)) 307 | } 308 | } 309 | 310 | /// Make a request to the server. Returns a promise that resolves to 311 | /// the response or rejects with a failure message. You'll probably 312 | /// want to use types from the `vscode-languageserver-protocol` 313 | /// package for the type parameters. 314 | /// 315 | /// The caller is responsible for 316 | /// [synchronizing](#lsp-client.LSPClient.sync) state before the 317 | /// request and correctly handling state drift caused by local 318 | /// changes that happend during the request. 319 | request(method: string, params: Params): Promise { 320 | if (!this.transport) return Promise.reject(new Error("Client not connected")) 321 | return this.initializing.then(() => this.requestInner(method, params).promise) 322 | } 323 | 324 | private requestInner( 325 | method: string, 326 | params: Params, 327 | mapped = false 328 | ): Request { 329 | let id = ++this.nextReqID, data: lsp.RequestMessage = { 330 | jsonrpc: "2.0", 331 | id, 332 | method, 333 | params: params as any 334 | } 335 | let req = new Request(id, params, setTimeout(() => this.timeoutRequest(req), this.timeout)) 336 | this.requests.push(req) 337 | try { this.transport!.send(JSON.stringify(data)) } 338 | catch(e) { req.reject(e) } 339 | return req 340 | } 341 | 342 | /// Send a notification to the server. 343 | notification(method: string, params: Params) { 344 | if (!this.transport) return 345 | this.initializing.then(() => { 346 | let data: lsp.NotificationMessage = { 347 | jsonrpc: "2.0", 348 | method, 349 | params: params as any 350 | } 351 | this.transport!.send(JSON.stringify(data)) 352 | }) 353 | } 354 | 355 | /// Cancel the in-progress request with the given parameter value 356 | /// (which is compared by identity). 357 | cancelRequest(params: any) { 358 | let found = this.requests.find(r => r.params === params) 359 | if (found) this.notification("$/cancelRequest", found.id) 360 | } 361 | 362 | /// @internal 363 | hasCapability(name: keyof lsp.ServerCapabilities) { 364 | return this.serverCapabilities ? !!this.serverCapabilities[name] : null 365 | } 366 | 367 | /// Create a [workspace mapping](#lsp-client.WorkspaceMapping) that 368 | /// tracks changes to files in this client's workspace, relative to 369 | /// the moment where it was created. Make sure you call 370 | /// [`destroy`](#lsp-client.WorkspaceMapping.destroy) on the mapping 371 | /// when you're done with it. 372 | workspaceMapping() { 373 | let mapping = new WorkspaceMapping(this) 374 | this.activeMappings.push(mapping) 375 | return mapping 376 | } 377 | 378 | /// Run the given promise with a [workspace 379 | /// mapping](#lsp-client.WorkspaceMapping) active. Automatically 380 | /// release the mapping when the promise resolves or rejects. 381 | withMapping(f: (mapping: WorkspaceMapping) => Promise): Promise { 382 | let mapping = this.workspaceMapping() 383 | return f(mapping).finally(() => mapping.destroy()) 384 | } 385 | 386 | /// Push any [pending changes](#lsp-client.Workspace.syncFiles) in 387 | /// the open files to the server. You'll want to call this before 388 | /// most types of requests, to make sure the server isn't working 389 | /// with outdated information. 390 | sync() { 391 | for (let {file, changes, prevDoc} of this.workspace.syncFiles()) { 392 | for (let mapping of this.activeMappings) 393 | mapping.addChanges(file.uri, changes) 394 | if (this.supportSync) this.notification("textDocument/didChange", { 395 | textDocument: {uri: file.uri, version: file.version}, 396 | contentChanges: contentChangesFor(file, prevDoc, changes, this.supportSync == 2 /* Incremental */) 397 | }) 398 | } 399 | } 400 | 401 | private timeoutRequest(req: Request) { 402 | let index = this.requests.indexOf(req) 403 | if (index > -1) { 404 | req.reject(new Error("Request timed out")) 405 | this.requests.splice(index, 1) 406 | } 407 | } 408 | } 409 | 410 | const enum Sync { AlwaysIfSmaller = 1024 } 411 | 412 | function contentChangesFor( 413 | file: WorkspaceFile, 414 | startDoc: Text, 415 | changes: ChangeSet, 416 | supportInc: boolean 417 | ): lsp.TextDocumentContentChangeEvent[] { 418 | if (!supportInc || file.doc.length < Sync.AlwaysIfSmaller) 419 | return [{text: file.doc.toString()}] 420 | let events: lsp.TextDocumentContentChangeEvent[] = [] 421 | changes.iterChanges((fromA, toA, fromB, toB, inserted) => { 422 | events.push({ 423 | range: {start: toPosition(startDoc, fromA), end: toPosition(startDoc, toA)}, 424 | text: inserted.toString() 425 | }) 426 | }) 427 | return events.reverse() 428 | } 429 | -------------------------------------------------------------------------------- /src/completion.ts: -------------------------------------------------------------------------------- 1 | import type * as lsp from "vscode-languageserver-protocol" 2 | import {EditorState, Extension} from "@codemirror/state" 3 | import {CompletionSource, Completion, CompletionContext, snippet, autocompletion} from "@codemirror/autocomplete" 4 | import {LSPPlugin} from "./plugin" 5 | 6 | /// Register the [language server completion 7 | /// source](#lsp-client.serverCompletionSource) as an autocompletion 8 | /// source. 9 | export function serverCompletion(config: { 10 | /// By default, the completion source that asks the language server 11 | /// for completions is added as a regular source, in addition to any 12 | /// other sources. Set this to true to make it replace all 13 | /// completion sources. 14 | override?: boolean 15 | } = {}): Extension { 16 | if (config.override) { 17 | return autocompletion({override: [serverCompletionSource]}) 18 | } else { 19 | let data = [{autocomplete: serverCompletionSource}] 20 | return [autocompletion(), EditorState.languageData.of(() => data)] 21 | } 22 | } 23 | 24 | function getCompletions(plugin: LSPPlugin, pos: number, context: lsp.CompletionContext, abort?: CompletionContext) { 25 | if (plugin.client.hasCapability("completionProvider") === false) return Promise.resolve(null) 26 | plugin.client.sync() 27 | let params: lsp.CompletionParams = { 28 | position: plugin.toPosition(pos), 29 | textDocument: {uri: plugin.uri}, 30 | context 31 | } 32 | if (abort) abort.addEventListener("abort", () => plugin.client.cancelRequest(params)) 33 | return plugin.client.request( 34 | "textDocument/completion", params) 35 | } 36 | 37 | // Look for non-alphanumeric prefixes in the completions, and return a 38 | // regexp that matches them, to use in validFor 39 | function prefixRegexp(items: readonly lsp.CompletionItem[]) { 40 | let step = Math.ceil(items.length / 50), prefixes: string[] = [] 41 | for (let i = 0; i < items.length; i += step) { 42 | let item = items[i], text = item.textEdit?.newText || item.textEditText || item.insertText || item.label 43 | if (!/^\w/.test(text)) { 44 | let prefix = /^[^\w]*/.exec(text)![0] 45 | if (prefixes.indexOf(prefix) < 0) prefixes.push(prefix) 46 | } 47 | } 48 | if (!prefixes.length) return /^\w*$/ 49 | return new RegExp("^(?:" + prefixes.map((RegExp as any).escape || (s => s.replace(/[^\w\s]/g, "\\$&"))).join("|") + ")?\w*$") 50 | } 51 | 52 | /// A completion source that requests completions from a language 53 | /// server. 54 | export const serverCompletionSource: CompletionSource = context => { 55 | const plugin = context.view && LSPPlugin.get(context.view) 56 | if (!plugin) return null 57 | let triggerChar = "" 58 | if (!context.explicit) { 59 | triggerChar = context.view.state.sliceDoc(context.pos - 1, context.pos) 60 | let triggers = plugin.client.serverCapabilities?.completionProvider?.triggerCharacters 61 | if (!/[a-zA-Z_]/.test(triggerChar) && !(triggers && triggers.indexOf(triggerChar) > -1)) return null 62 | } 63 | return getCompletions(plugin, context.pos, { 64 | triggerCharacter: triggerChar, 65 | triggerKind: context.explicit ? 1 /* Invoked */ : 2 /* TriggerCharacter */ 66 | }, context).then(result => { 67 | if (!result) return null 68 | if (Array.isArray(result)) result = {items: result} as lsp.CompletionList 69 | let {from, to} = completionResultRange(context, result) 70 | let defaultCommitChars = result.itemDefaults?.commitCharacters 71 | 72 | return { 73 | from, to, 74 | options: result.items.map(item => { 75 | let text = item.textEdit?.newText || item.textEditText || item.insertText || item.label 76 | let option: Completion = { 77 | label: text, 78 | type: item.kind && kindToType[item.kind], 79 | } 80 | if (item.commitCharacters && item.commitCharacters != defaultCommitChars) 81 | option.commitCharacters = item.commitCharacters 82 | if (item.detail) option.detail = item.detail 83 | if (item.insertTextFormat == 2 /* Snippet */) option.apply = (view, c, from, to) => snippet(text)(view, c, from, to) 84 | if (item.documentation) option.info = () => renderDocInfo(plugin, item.documentation!) 85 | return option 86 | }), 87 | commitCharacters: defaultCommitChars, 88 | validFor: prefixRegexp(result.items), 89 | map: (result, changes) => ({...result, from: changes.mapPos(result.from)}), 90 | } 91 | }, err => { 92 | if ("code" in err && (err as lsp.ResponseError).code == -32800 /* RequestCancelled */) 93 | return null 94 | throw err 95 | }) 96 | } 97 | 98 | function completionResultRange(cx: CompletionContext, result: lsp.CompletionList): {from: number, to: number} { 99 | if (!result.items.length) return {from: cx.pos, to: cx.pos} 100 | let defaultRange = result.itemDefaults?.editRange, item0 = result.items[0] 101 | let range = defaultRange ? ("insert" in defaultRange ? defaultRange.insert : defaultRange) 102 | : item0.textEdit ? ("range" in item0.textEdit ? item0.textEdit.range : item0.textEdit.insert) 103 | : null 104 | if (!range) return cx.state.wordAt(cx.pos) || {from: cx.pos, to: cx.pos} 105 | let line = cx.state.doc.lineAt(cx.pos) 106 | return {from: line.from + range.start.character, to: line.from + range.end.character} 107 | } 108 | 109 | function renderDocInfo(plugin: LSPPlugin, doc: string | lsp.MarkupContent) { 110 | let elt = document.createElement("div") 111 | elt.className = "cm-lsp-documentation cm-lsp-completion-documentation" 112 | elt.innerHTML = plugin.docToHTML(doc) 113 | return elt 114 | } 115 | 116 | const kindToType: {[kind: number]: string} = { 117 | 1: "text", // Text 118 | 2: "method", // Method 119 | 3: "function", // Function 120 | 4: "class", // Constructor 121 | 5: "property", // Field 122 | 6: "variable", // Variable 123 | 7: "class", // Class 124 | 8: "interface", // Interface 125 | 9: "namespace", // Module 126 | 10: "property", // Property 127 | 11: "keyword", // Unit 128 | 12: "constant", // Value 129 | 13: "constant", // Enum 130 | 14: "keyword", // Keyword 131 | 16: "constant", // Color 132 | 20: "constant", // EnumMember 133 | 21: "constant", // Constant 134 | 22: "class", // Struct 135 | 25: "type" // TypeParameter 136 | } 137 | -------------------------------------------------------------------------------- /src/definition.ts: -------------------------------------------------------------------------------- 1 | import type * as lsp from "vscode-languageserver-protocol" 2 | import {EditorView, Command, KeyBinding} from "@codemirror/view" 3 | import {LSPPlugin} from "./plugin" 4 | 5 | function getDefinition(plugin: LSPPlugin, pos: number) { 6 | return plugin.client.request("textDocument/definition", { 7 | textDocument: {uri: plugin.uri}, 8 | position: plugin.toPosition(pos) 9 | }) 10 | } 11 | 12 | function getDeclaration(plugin: LSPPlugin, pos: number) { 13 | return plugin.client.request("textDocument/declaration", { 14 | textDocument: {uri: plugin.uri}, 15 | position: plugin.toPosition(pos) 16 | }) 17 | } 18 | 19 | function getTypeDefinition(plugin: LSPPlugin, pos: number) { 20 | return plugin.client.request("textDocument/typeDefinition", { 21 | textDocument: {uri: plugin.uri}, 22 | position: plugin.toPosition(pos) 23 | }) 24 | } 25 | 26 | function getImplementation(plugin: LSPPlugin, pos: number) { 27 | return plugin.client.request("textDocument/implementation", { 28 | textDocument: {uri: plugin.uri}, 29 | position: plugin.toPosition(pos) 30 | }) 31 | } 32 | 33 | function jumpToOrigin(view: EditorView, type: {get: typeof getDefinition, capability: keyof lsp.ServerCapabilities}): boolean { 34 | const plugin = LSPPlugin.get(view) 35 | if (!plugin || plugin.client.hasCapability(type.capability) === false) return false 36 | plugin.client.sync() 37 | plugin.client.withMapping(mapping => type.get(plugin, view.state.selection.main.head).then(response => { 38 | if (!response) return 39 | let loc = Array.isArray(response) ? response[0] : response 40 | return (loc.uri == plugin.uri ? Promise.resolve(view) : plugin.client.workspace.displayFile(loc.uri)).then(target => { 41 | if (!target) return 42 | let pos = mapping.getMapping(loc.uri) ? mapping.mapPosition(loc.uri, loc.range.start) 43 | : plugin.fromPosition(loc.range.start, target.state.doc) 44 | target.dispatch({selection: {anchor: pos}, scrollIntoView: true, userEvent: "select.definition"}) 45 | }) 46 | }, error => plugin.reportError("Find definition failed", error))) 47 | return true 48 | } 49 | 50 | /// Jump to the definition of the symbol at the cursor. To support 51 | /// cross-file jumps, you'll need to implement 52 | /// [`Workspace.displayFile`](#lsp-client.Workspace.displayFile). 53 | export const jumpToDefinition: Command = view => jumpToOrigin(view, { 54 | get: getDefinition, 55 | capability: "definitionProvider" 56 | }) 57 | 58 | /// Jump to the declaration of the symbol at the cursor. 59 | export const jumpToDeclaration: Command = view => jumpToOrigin(view, { 60 | get: getDeclaration, 61 | capability: "declarationProvider" 62 | }) 63 | 64 | /// Jump to the type definition of the symbol at the cursor. 65 | export const jumpToTypeDefinition: Command = view => jumpToOrigin(view, { 66 | get: getTypeDefinition, 67 | capability: "typeDefinitionProvider" 68 | }) 69 | 70 | /// Jump to the implementation of the symbol at the cursor. 71 | export const jumpToImplementation: Command = view => jumpToOrigin(view, { 72 | get: getImplementation, 73 | capability: "implementationProvider" 74 | }) 75 | 76 | /// Binds F12 to [`jumpToDefinition`](#lsp-client.jumpToDefinition). 77 | export const jumpToDefinitionKeymap: readonly KeyBinding[] = [ 78 | {key: "F12", run: jumpToDefinition, preventDefault: true}, 79 | ] 80 | -------------------------------------------------------------------------------- /src/formatting.ts: -------------------------------------------------------------------------------- 1 | import type * as lsp from "vscode-languageserver-protocol" 2 | import {Command, KeyBinding} from "@codemirror/view" 3 | import {ChangeSpec} from "@codemirror/state" 4 | import {indentUnit, getIndentUnit} from "@codemirror/language" 5 | import {LSPPlugin} from "./plugin" 6 | 7 | function getFormatting(plugin: LSPPlugin, options: lsp.FormattingOptions) { 8 | return plugin.client.request("textDocument/formatting", { 9 | options, 10 | textDocument: {uri: plugin.uri}, 11 | }) 12 | } 13 | 14 | /// This command asks the language server to reformat the document, 15 | /// and then applies the changes it returns. 16 | export const formatDocument: Command = view => { 17 | const plugin = LSPPlugin.get(view) 18 | if (!plugin) return false 19 | plugin.client.sync() 20 | plugin.client.withMapping(mapping => getFormatting(plugin, { 21 | tabSize: getIndentUnit(view.state), 22 | insertSpaces: view.state.facet(indentUnit).indexOf("\t") < 0, 23 | }).then(response => { 24 | if (!response) return 25 | let changed = mapping.getMapping(plugin.uri) 26 | let changes: ChangeSpec[] = [] 27 | for (let change of response) { 28 | let from = mapping.mapPosition(plugin.uri, change.range.start) 29 | let to = mapping.mapPosition(plugin.uri, change.range.end) 30 | if (changed) { 31 | // Don't try to apply the changes if code inside of any of them was touched 32 | if (changed.touchesRange(from, to)) return 33 | from = changed.mapPos(from, 1) 34 | to = changed.mapPos(to, -1) 35 | } 36 | changes.push({from, to, insert: change.newText}) 37 | } 38 | view.dispatch({ 39 | changes, 40 | userEvent: "format" 41 | }) 42 | }, err => { 43 | plugin.reportError("Formatting request failed", err) 44 | })) 45 | return true 46 | } 47 | 48 | /// A keymap that binds Shift-Alt-f to 49 | /// [`formatDocument`](#lsp-client.formatDocument). 50 | export const formatKeymap: readonly KeyBinding[] = [ 51 | {key: "Shift-Alt-f", run: formatDocument, preventDefault: true} 52 | ] 53 | -------------------------------------------------------------------------------- /src/hover.ts: -------------------------------------------------------------------------------- 1 | import type * as lsp from "vscode-languageserver-protocol" 2 | import {EditorView, Tooltip, hoverTooltip} from "@codemirror/view" 3 | import {Extension} from "@codemirror/state" 4 | import {language as languageFacet, highlightingFor} from "@codemirror/language" 5 | import {highlightCode} from "@lezer/highlight" 6 | import {fromPosition} from "./pos" 7 | import {escHTML} from "./text" 8 | import {LSPPlugin} from "./plugin" 9 | 10 | /// Create an extension that queries the language server for hover 11 | /// tooltips when the user hovers over the code with their pointer, 12 | /// and displays a tooltip when the server provides one. 13 | export function hoverTooltips(config: {hoverTime?: number} = {}): Extension { 14 | return hoverTooltip(lspTooltipSource, { 15 | hideOn: tr => tr.docChanged, 16 | hoverTime: config.hoverTime 17 | }) 18 | } 19 | 20 | function hoverRequest(plugin: LSPPlugin, pos: number) { 21 | if (plugin.client.hasCapability("hoverProvider") === false) return Promise.resolve(null) 22 | plugin.client.sync() 23 | return plugin.client.request("textDocument/hover", { 24 | position: plugin.toPosition(pos), 25 | textDocument: {uri: plugin.uri}, 26 | }) 27 | } 28 | 29 | function lspTooltipSource(view: EditorView, pos: number): Promise { 30 | const plugin = LSPPlugin.get(view) 31 | if (!plugin) return Promise.resolve(null) 32 | return hoverRequest(plugin, pos).then(result => { 33 | if (!result) return null 34 | return { 35 | pos: result.range ? fromPosition(view.state.doc, result.range.start) : pos, 36 | end: result.range ? fromPosition(view.state.doc, result.range.end) : pos, 37 | create() { 38 | let elt = document.createElement("div") 39 | elt.className = "cm-lsp-hover-tooltip cm-lsp-documentation" 40 | elt.innerHTML = renderTooltipContent(plugin, result.contents) 41 | return {dom: elt} 42 | }, 43 | above: true 44 | } 45 | }) 46 | } 47 | 48 | function renderTooltipContent( 49 | plugin: LSPPlugin, 50 | value: string | lsp.MarkupContent | lsp.MarkedString | lsp.MarkedString[] 51 | ) { 52 | if (Array.isArray(value)) return value.map(m => renderCode(plugin, m)).join("
") 53 | if (typeof value == "string" || typeof value == "object" && "language" in value) return renderCode(plugin, value) 54 | return plugin.docToHTML(value) 55 | } 56 | 57 | function renderCode(plugin: LSPPlugin, code: lsp.MarkedString) { 58 | let {language, value} = typeof code == "string" ? {language: null, value: code} : code 59 | let lang = plugin.client.config.highlightLanguage && plugin.client.config.highlightLanguage(language || "") 60 | if (!lang) { 61 | let viewLang = plugin.view.state.facet(languageFacet) 62 | if (viewLang && (!language || viewLang.name == language)) lang = viewLang 63 | } 64 | if (!lang) return escHTML(value) 65 | let result = "" 66 | highlightCode(value, lang.parser.parse(value), {style: tags => highlightingFor(plugin.view.state, tags)}, (text, cls) => { 67 | result += cls ? `${escHTML(text)}` : escHTML(text) 68 | }, () => { 69 | result += "
" 70 | }) 71 | return result 72 | } 73 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {Transport, LSPClient, LSPClientConfig, WorkspaceMapping} from "./client" 2 | export {LSPPlugin} from "./plugin" 3 | export {Workspace, WorkspaceFile} from "./workspace" 4 | export {serverCompletion, serverCompletionSource} from "./completion" 5 | export {hoverTooltips} from "./hover" 6 | export {formatDocument, formatKeymap} from "./formatting" 7 | export {renameSymbol, renameKeymap} from "./rename" 8 | export {signatureHelp, nextSignature, prevSignature, showSignatureHelp, signatureKeymap} from "./signature" 9 | export {jumpToDefinition, jumpToDeclaration, jumpToTypeDefinition, jumpToImplementation, jumpToDefinitionKeymap} from "./definition" 10 | export {findReferences, closeReferencePanel, findReferencesKeymap} from "./references" 11 | 12 | import {Extension} from "@codemirror/state" 13 | import {keymap} from "@codemirror/view" 14 | import {LSPClient} from "./client" 15 | import {LSPPlugin} from "./plugin" 16 | import {serverCompletion} from "./completion" 17 | import {hoverTooltips} from "./hover" 18 | import {formatKeymap} from "./formatting" 19 | import {renameKeymap} from "./rename" 20 | import {signatureHelp} from "./signature" 21 | import {jumpToDefinitionKeymap} from "./definition" 22 | import {findReferencesKeymap} from "./references" 23 | 24 | /// Returns an extension that enables the [LSP 25 | /// plugin](#lsp-client.LSPPlugin) and all other features provided by 26 | /// this package. You can also pick and choose individual extensions 27 | /// from the exports. In that case, make sure to also include 28 | /// [`LSPPlugin.create`](#lsp-client.LSPPlugin^create) in your 29 | /// extensions, or the others will not work. 30 | export function languageServerSupport(client: LSPClient, uri: string, languageID?: string): Extension { 31 | return [ 32 | LSPPlugin.create(client, uri, languageID), 33 | serverCompletion(), 34 | hoverTooltips(), 35 | keymap.of([...formatKeymap, ...renameKeymap, ...jumpToDefinitionKeymap, ...findReferencesKeymap]), 36 | signatureHelp() 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import type * as lsp from "vscode-languageserver-protocol" 2 | import {EditorView, ViewPlugin, ViewUpdate, showDialog} from "@codemirror/view" 3 | import {ChangeSet, Text, Extension} from "@codemirror/state" 4 | import {language} from "@codemirror/language" 5 | import {type LSPClient} from "./client" 6 | import {docToHTML, withContext} from "./text" 7 | import {toPosition, fromPosition} from "./pos" 8 | import {lspTheme} from "./theme" 9 | 10 | /// A plugin that connects a given editor to a language server client. 11 | export class LSPPlugin { 12 | /// The client connection. 13 | client: LSPClient 14 | /// The URI of this file. 15 | uri: string 16 | 17 | /// @internal 18 | constructor( 19 | /// The editor view that this plugin belongs to. 20 | readonly view: EditorView, 21 | {client, uri, languageID}: {client: LSPClient, uri: string, languageID?: string} 22 | ) { 23 | this.client = client 24 | this.uri = uri 25 | if (!languageID) { 26 | let lang = view.state.facet(language) 27 | languageID = lang ? lang.name : "" 28 | } 29 | client.workspace.openFile(uri, languageID, view) 30 | this.unsyncedChanges = ChangeSet.empty(view.state.doc.length) 31 | } 32 | 33 | /// Render a doc string from the server to HTML. 34 | docToHTML(value: string | lsp.MarkupContent, defaultKind: lsp.MarkupKind = "plaintext") { 35 | let html = withContext(this.view, this.client.config.highlightLanguage, () => docToHTML(value, defaultKind)) 36 | return this.client.config.sanitizeHTML ? this.client.config.sanitizeHTML(html) : html 37 | } 38 | 39 | /// Convert a CodeMirror document offset into an LSP `{line, 40 | /// character}` object. Defaults to using the view's current 41 | /// document, but can be given another one. 42 | toPosition(pos: number, doc: Text = this.view.state.doc) { 43 | return toPosition(doc, pos) 44 | } 45 | 46 | /// Convert an LSP `{line, character}` object to a CodeMirror 47 | /// document offset. 48 | fromPosition(pos: lsp.Position, doc: Text = this.view.state.doc) { 49 | return fromPosition(doc, pos) 50 | } 51 | 52 | /// Display an error in this plugin's editor. 53 | reportError(message: string, err: any) { 54 | showDialog(this.view, { 55 | label: this.view.state.phrase(message) + ": " + (err.message || err), 56 | class: "cm-lsp-message cm-lsp-message-error", 57 | top: true 58 | }) 59 | } 60 | 61 | /// The changes accumulated in this editor that have not been sent 62 | /// to the server yet. 63 | unsyncedChanges: ChangeSet 64 | 65 | /// Reset the [unsynced 66 | /// changes](#lsp-client.LSPPlugin.unsyncedChanges). Should probably 67 | /// only be called by a [workspace](#lsp-client.Workspace). 68 | clear() { 69 | this.unsyncedChanges = ChangeSet.empty(this.view.state.doc.length) 70 | } 71 | 72 | /// @internal 73 | update(update: ViewUpdate) { 74 | if (update.docChanged) 75 | this.unsyncedChanges = this.unsyncedChanges.compose(update.changes) 76 | } 77 | 78 | /// @internal 79 | destroy() { 80 | this.client.workspace.closeFile(this.uri, this.view) 81 | } 82 | 83 | /// Get the LSP plugin associated with an editor, if any. 84 | static get(view: EditorView) { 85 | return view.plugin(lspPlugin) 86 | } 87 | 88 | /// Create an editor extension that connects that editor to the 89 | /// given LSP client. This extension is necessary to use LSP-related 90 | /// functionality exported by this package. Creating an editor with 91 | /// this plugin will cause 92 | /// [`openFile`](#lsp-client.Workspace.openFile) to be called on the 93 | /// workspace. 94 | /// 95 | /// By default, the language ID given to the server for this file is 96 | /// derived from the editor's language configuration via 97 | /// [`Language.name`](#language.Language.name). You can pass in 98 | /// a specific ID as a third parameter. 99 | static create(client: LSPClient, fileURI: string, languageID?: string): Extension { 100 | return [lspPlugin.of({client, uri: fileURI, languageID}), lspTheme] 101 | } 102 | } 103 | 104 | export const lspPlugin = ViewPlugin.fromClass(LSPPlugin) 105 | -------------------------------------------------------------------------------- /src/pos.ts: -------------------------------------------------------------------------------- 1 | import type * as lsp from "vscode-languageserver-protocol" 2 | import {Text} from "@codemirror/state" 3 | 4 | export function toPosition(doc: Text, pos: number): lsp.Position { 5 | let line = doc.lineAt(pos) 6 | return {line: line.number - 1, character: pos - line.from} 7 | } 8 | 9 | export function fromPosition(doc: Text, pos: lsp.Position): number { 10 | let line = doc.line(pos.line + 1) 11 | return line.from + pos.character 12 | } 13 | 14 | -------------------------------------------------------------------------------- /src/references.ts: -------------------------------------------------------------------------------- 1 | import type * as lsp from "vscode-languageserver-protocol" 2 | import {Command, KeyBinding, showPanel, PanelConstructor, EditorView} from "@codemirror/view" 3 | import {StateField, StateEffect} from "@codemirror/state" 4 | import {LSPPlugin} from "./plugin" 5 | import {WorkspaceFile} from "./workspace" 6 | import {WorkspaceMapping} from "./client" 7 | 8 | function getReferences(plugin: LSPPlugin, pos: number) { 9 | return plugin.client.request("textDocument/references", { 10 | textDocument: {uri: plugin.uri}, 11 | position: plugin.toPosition(pos), 12 | context: {includeDeclaration: true} 13 | }) 14 | } 15 | 16 | type ReferenceLocation = {file: WorkspaceFile, range: lsp.Range} 17 | 18 | /// Ask the server to locate all references to the symbol at the 19 | /// cursor. When the server can provide such references, show them as 20 | /// a list in a panel. 21 | export const findReferences: Command = view => { 22 | const plugin = LSPPlugin.get(view) 23 | if (!plugin || plugin.client.hasCapability("referencesProvider") === false) return false 24 | plugin.client.sync() 25 | let mapping = plugin.client.workspaceMapping(), passedMapping = false 26 | getReferences(plugin, view.state.selection.main.head).then(response => { 27 | if (!response) return 28 | return Promise.all(response.map(loc => plugin.client.workspace.requestFile(loc.uri).then(file => { 29 | return file ? {file, range: loc.range} : null 30 | }))).then(resolved => { 31 | let locs = resolved.filter(l => l) as ReferenceLocation[] 32 | if (locs.length) { 33 | displayReferences(plugin.view, locs, mapping) 34 | passedMapping = true 35 | } 36 | }) 37 | }, err => plugin.reportError("Finding references failed", err)).finally(() => { 38 | if (!passedMapping) mapping.destroy() 39 | }) 40 | return true 41 | } 42 | 43 | /// Close the reference panel, if it is open. 44 | export const closeReferencePanel: Command = view => { 45 | if (!view.state.field(referencePanel, false)) return false 46 | view.dispatch({effects: setReferencePanel.of(null)}) 47 | return true 48 | } 49 | 50 | const referencePanel = StateField.define({ 51 | create() { return null }, 52 | 53 | update(panel, tr) { 54 | for (let e of tr.effects) if (e.is(setReferencePanel)) return e.value 55 | return panel 56 | }, 57 | 58 | provide: f => showPanel.from(f) 59 | }) 60 | 61 | const setReferencePanel = StateEffect.define() 62 | 63 | function displayReferences(view: EditorView, locs: readonly ReferenceLocation[], mapping: WorkspaceMapping) { 64 | let panel = createReferencePanel(locs, mapping) 65 | let effect = view.state.field(referencePanel, false) === undefined 66 | ? StateEffect.appendConfig.of(referencePanel.init(() => panel)) 67 | : setReferencePanel.of(panel) 68 | view.dispatch({effects: effect}) 69 | } 70 | 71 | function createReferencePanel(locs: readonly ReferenceLocation[], mapping: WorkspaceMapping): PanelConstructor { 72 | let created = false 73 | // Make sure that if this panel isn't used, the mapping still gets destroyed 74 | setTimeout(() => {if (!created) mapping.destroy()}, 500) 75 | 76 | return view => { 77 | created = true 78 | let prefixLen = findCommonPrefix(locs.map(l => l.file.uri)) 79 | let panel = document.createElement("div"), curFile = null 80 | panel.className = "cm-lsp-reference-panel" 81 | panel.tabIndex = 0 82 | panel.role = "listbox" 83 | panel.setAttribute("aria-label", view.state.phrase("Reference list")) 84 | let options: HTMLElement[] = [] 85 | for (let {file, range} of locs) { 86 | let fileName = file.uri.slice(prefixLen) 87 | if (fileName != curFile) { 88 | curFile = fileName 89 | let header = panel.appendChild(document.createElement("div")) 90 | header.className = "cm-lsp-reference-file" 91 | header.textContent = fileName 92 | } 93 | let entry = panel.appendChild(document.createElement("div")) 94 | entry.className = "cm-lsp-reference" 95 | entry.role = "option" 96 | let from = mapping.mapPosition(file.uri, range.start, 1), to = mapping.mapPosition(file.uri, range.end, -1) 97 | let view = file.getView(), line = (view ? view.state.doc : file.doc).lineAt(from) 98 | let lineNumber = entry.appendChild(document.createElement("span")) 99 | lineNumber.className = "cm-lsp-reference-line" 100 | lineNumber.textContent = (line.number + ": ").padStart(5, " ") 101 | let textBefore = line.text.slice(Math.max(0, from - line.from - 50), from - line.from) 102 | if (textBefore) entry.appendChild(document.createTextNode(textBefore)) 103 | entry.appendChild(document.createElement("strong")).textContent = line.text.slice(from - line.from, to - line.from) 104 | let textAfter = line.text.slice(to - line.from, Math.min(line.length, 100 - textBefore.length)) 105 | if (textAfter) entry.appendChild(document.createTextNode(textAfter)) 106 | if (!options.length) entry.setAttribute("aria-selected", "true") 107 | options.push(entry) 108 | } 109 | 110 | function curSelection() { 111 | for (let i = 0; i < options.length; i++) { 112 | if (options[i].hasAttribute("aria-selected")) return i 113 | } 114 | return 0 115 | } 116 | function setSelection(index: number) { 117 | for (let i = 0; i < options.length; i++) { 118 | if (i == index) options[i].setAttribute("aria-selected", "true") 119 | else options[i].removeAttribute("aria-selected") 120 | } 121 | } 122 | function showReference(index: number) { 123 | let {file, range} = locs[index] 124 | let plugin = LSPPlugin.get(view) 125 | if (!plugin) return 126 | Promise.resolve(file.uri == plugin.uri ? view : plugin.client.workspace.displayFile(file.uri)).then(view => { 127 | if (!view) return 128 | let pos = mapping.mapPosition(file.uri, range.start, 1) 129 | view.focus() 130 | view.dispatch({ 131 | selection: {anchor: pos}, 132 | scrollIntoView: true 133 | }) 134 | }) 135 | } 136 | 137 | panel.addEventListener("keydown", event => { 138 | if (event.keyCode == 27) { // Escape 139 | closeReferencePanel(view) 140 | view.focus() 141 | } else if (event.keyCode == 38 || event.keyCode == 33) { // ArrowUp, PageUp 142 | setSelection((curSelection() - 1 + locs.length) % locs.length) 143 | } else if (event.keyCode == 40 || event.keyCode == 34) { // ArrowDown, PageDown 144 | setSelection((curSelection() + 1) % locs.length) 145 | } else if (event.keyCode == 36) { // Home 146 | setSelection(0) 147 | } else if (event.keyCode == 35) { // End 148 | setSelection(options.length - 1) 149 | } else if (event.keyCode == 13 || event.keyCode == 10) { // Enter, Space 150 | showReference(curSelection()) 151 | } else { 152 | return 153 | } 154 | event.preventDefault() 155 | }) 156 | panel.addEventListener("click", event => { 157 | for (let i = 0; i < options.length; i++) { 158 | if (options[i].contains(event.target as HTMLElement)) { 159 | setSelection(i) 160 | showReference(i) 161 | event.preventDefault() 162 | } 163 | } 164 | }) 165 | let dom = document.createElement("div") 166 | dom.appendChild(panel) 167 | let close = dom.appendChild(document.createElement("button")) 168 | close.className = "cm-dialog-close" 169 | close.textContent = "×" 170 | close.addEventListener("click", () => closeReferencePanel(view)) 171 | close.setAttribute("aria-label", view.state.phrase("close")) 172 | 173 | return { 174 | dom, 175 | destroy: () => mapping.destroy(), 176 | mount: () => panel.focus(), 177 | } 178 | } 179 | } 180 | 181 | function findCommonPrefix(uris: string[]) { 182 | let first = uris[0], prefix = first.length 183 | for (let i = 1; i < uris.length; i++) { 184 | let uri = uris[i], j = 0 185 | for (let e = Math.min(prefix, uri.length); j < e && first[j] == uri[j]; j++) {} 186 | prefix = j 187 | } 188 | while (prefix && first[prefix - 1] != "/") prefix-- 189 | return prefix 190 | } 191 | 192 | /// Binds Shift-F12 to [`findReferences`](#lsp-client.findReferences) 193 | /// and Escape to 194 | /// [`closeReferencePanel`](#lsp-client.closeReferencePanel). 195 | export const findReferencesKeymap: readonly KeyBinding[] = [ 196 | {key: "Shift-F12", run: findReferences, preventDefault: true}, 197 | {key: "Escape", run: closeReferencePanel}, 198 | ] 199 | -------------------------------------------------------------------------------- /src/rename.ts: -------------------------------------------------------------------------------- 1 | import type * as lsp from "vscode-languageserver-protocol" 2 | import {EditorView, Command, KeyBinding, showDialog, getDialog} from "@codemirror/view" 3 | import {LSPPlugin} from "./plugin" 4 | 5 | function getRename(plugin: LSPPlugin, pos: number, newName: string) { 6 | return plugin.client.request("textDocument/rename", { 7 | newName, 8 | position: plugin.toPosition(pos), 9 | textDocument: {uri: plugin.uri}, 10 | }) 11 | } 12 | 13 | /// This command will, if the cursor is over a word, prompt the user 14 | /// for a new name for that symbol, and ask the language server to 15 | /// perform a rename of that symbol. 16 | /// 17 | /// Note that this may affect files other than the one loaded into 18 | /// this view. See the 19 | /// [`Workspace.updateFile`](#lsp-client.Workspace.updateFile) 20 | /// method. 21 | export const renameSymbol: Command = view => { 22 | let wordRange = view.state.wordAt(view.state.selection.main.head) 23 | let plugin = LSPPlugin.get(view) 24 | if (!wordRange || !plugin || plugin.client.hasCapability("renameProvider") === false) return false 25 | const word = view.state.sliceDoc(wordRange.from, wordRange.to) 26 | let panel = getDialog(view, "cm-lsp-rename-panel") 27 | if (panel) { 28 | let input = panel.dom.querySelector("[name=name]") as HTMLInputElement 29 | input.value = word 30 | input.select() 31 | } else { 32 | let {close, result} = showDialog(view, { 33 | label: view.state.phrase("New name"), 34 | input: {name: "name", value: word}, 35 | focus: true, 36 | submitLabel: view.state.phrase("rename"), 37 | class: "cm-lsp-rename-panel", 38 | }) 39 | result.then(form => { 40 | view.dispatch({effects: close}) 41 | if (form) doRename(view, (form.elements.namedItem("name") as HTMLInputElement).value) 42 | }) 43 | } 44 | return true 45 | } 46 | 47 | function doRename(view: EditorView, newName: string) { 48 | const plugin = LSPPlugin.get(view) 49 | const word = view.state.wordAt(view.state.selection.main.head) 50 | if (!plugin || !word) return false 51 | 52 | plugin.client.sync() 53 | plugin.client.withMapping(mapping => getRename(plugin, word.from, newName).then(response => { 54 | if (!response) return 55 | uris: for (let uri in response.changes) { 56 | let lspChanges = response.changes[uri], file = plugin.client.workspace.getFile(uri) 57 | if (!lspChanges.length || !file) continue 58 | plugin.client.workspace.updateFile(uri, { 59 | changes: lspChanges.map(change => ({ 60 | from: mapping.mapPosition(uri, change.range.start), 61 | to: mapping.mapPosition(uri, change.range.end), 62 | insert: change.newText 63 | })), 64 | userEvent: "rename" 65 | }) 66 | } 67 | }, err => { 68 | plugin.reportError("Rename request failed", err) 69 | })) 70 | } 71 | 72 | /// A keymap that binds F2 to [`renameSymbol`](#lsp-client.renameSymbol). 73 | export const renameKeymap: readonly KeyBinding[] = [ 74 | {key: "F2", run: renameSymbol, preventDefault: true} 75 | ] 76 | -------------------------------------------------------------------------------- /src/signature.ts: -------------------------------------------------------------------------------- 1 | import type * as lsp from "vscode-languageserver-protocol" 2 | import {StateField, StateEffect, Prec, Extension} from "@codemirror/state" 3 | import {EditorView, ViewPlugin, ViewUpdate, keymap, KeyBinding, 4 | Tooltip, showTooltip, Command} from "@codemirror/view" 5 | import {LSPPlugin} from "./plugin" 6 | 7 | function getSignatureHelp(plugin: LSPPlugin, pos: number, context: lsp.SignatureHelpContext) { 8 | if (plugin.client.hasCapability("signatureHelpProvider") === false) return Promise.resolve(null) 9 | plugin.client.sync() 10 | return plugin.client.request("textDocument/signatureHelp", { 11 | context, 12 | position: plugin.toPosition(pos), 13 | textDocument: {uri: plugin.uri}, 14 | }) 15 | } 16 | 17 | const signaturePlugin = ViewPlugin.fromClass(class { 18 | activeRequest: {pos: number, drop: boolean} | null = null 19 | delayedRequest: number = 0 20 | 21 | update(update: ViewUpdate) { 22 | if (this.activeRequest) { 23 | if (update.selectionSet) { 24 | this.activeRequest.drop = true 25 | this.activeRequest = null 26 | } else if (update.docChanged) { 27 | this.activeRequest.pos = update.changes.mapPos(this.activeRequest.pos) 28 | } 29 | } 30 | 31 | const plugin = LSPPlugin.get(update.view) 32 | if (!plugin) return 33 | const sigState = update.view.state.field(signatureState) 34 | let triggerCharacter = "" 35 | if (update.docChanged && update.transactions.some(tr => tr.isUserEvent("input.type"))) { 36 | const serverConf = plugin.client.serverCapabilities?.signatureHelpProvider 37 | const triggers = (serverConf?.triggerCharacters || []).concat(sigState && serverConf?.retriggerCharacters || []) 38 | if (triggers) { 39 | update.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { 40 | let ins = inserted.toString() 41 | if (ins) for (let ch of triggers) { 42 | if (ins.indexOf(ch) > -1) triggerCharacter = ch 43 | } 44 | }) 45 | } 46 | } 47 | 48 | if (triggerCharacter) { 49 | this.startRequest(plugin, { 50 | triggerKind: 2 /* TriggerCharacter */, 51 | isRetrigger: !!sigState, 52 | triggerCharacter, 53 | activeSignatureHelp: sigState ? sigState.data : undefined 54 | }) 55 | } else if (sigState && update.selectionSet) { 56 | if (this.delayedRequest) clearTimeout(this.delayedRequest) 57 | this.delayedRequest = setTimeout(() => { 58 | this.startRequest(plugin, { 59 | triggerKind: 3 /* ContentChange */, 60 | isRetrigger: true, 61 | activeSignatureHelp: sigState.data, 62 | }) 63 | }, 250) 64 | } 65 | } 66 | 67 | startRequest(plugin: LSPPlugin, context: lsp.SignatureHelpContext) { 68 | if (this.delayedRequest) clearTimeout(this.delayedRequest) 69 | let {view} = plugin, pos = view.state.selection.main.head 70 | if (this.activeRequest) this.activeRequest.drop = true 71 | let req = this.activeRequest = {pos, drop: false} 72 | getSignatureHelp(plugin, pos, context).then(result => { 73 | if (req.drop) return 74 | if (result && result.signatures.length) { 75 | let cur = view.state.field(signatureState) 76 | let same = cur && sameSignatures(cur.data, result) 77 | let active = same && context.triggerKind == 3 ? cur!.active : result.activeSignature ?? 0 78 | // Don't update at all if nothing changed 79 | if (same && sameActiveParam(cur!.data, result, active)) return 80 | view.dispatch({effects: signatureEffect.of({ 81 | data: result, 82 | active, 83 | pos: same ? cur!.tooltip.pos : req.pos 84 | })}) 85 | } else if (view.state.field(signatureState)) { 86 | view.dispatch({effects: signatureEffect.of(null)}) 87 | } 88 | }, context.triggerKind == 1 /* Invoked */ ? err => plugin.reportError("Signature request failed", err) : undefined) 89 | } 90 | 91 | destroy() { 92 | if (this.delayedRequest) clearTimeout(this.delayedRequest) 93 | if (this.activeRequest) this.activeRequest.drop = true 94 | } 95 | }) 96 | 97 | function sameSignatures(a: lsp.SignatureHelp, b: lsp.SignatureHelp) { 98 | if (a.signatures.length != b.signatures.length) return false 99 | return a.signatures.every((s, i) => s.label == b.signatures[i].label) 100 | } 101 | 102 | function sameActiveParam(a: lsp.SignatureHelp, b: lsp.SignatureHelp, active: number) { 103 | return (a.signatures[active].activeParameter ?? a.activeParameter) == 104 | (b.signatures[active].activeParameter ?? b.activeParameter) 105 | } 106 | 107 | class SignatureState { 108 | constructor( 109 | readonly data: lsp.SignatureHelp, 110 | readonly active: number, 111 | readonly tooltip: Tooltip 112 | ) {} 113 | } 114 | 115 | const signatureState = StateField.define({ 116 | create() { return null }, 117 | update(sig, tr) { 118 | for (let e of tr.effects) if (e.is(signatureEffect)) { 119 | if (e.value) { 120 | return new SignatureState(e.value.data, e.value.active, signatureTooltip(e.value.data, e.value.active, e.value.pos)) 121 | } else { 122 | return null 123 | } 124 | } 125 | if (sig && tr.docChanged) 126 | return new SignatureState(sig.data, sig.active, {...sig.tooltip, pos: tr.changes.mapPos(sig.tooltip.pos)}) 127 | return sig 128 | }, 129 | provide: f => showTooltip.from(f, sig => sig && sig.tooltip) 130 | }) 131 | 132 | const signatureEffect = StateEffect.define<{data: lsp.SignatureHelp, active: number, pos: number} | null>() 133 | 134 | function signatureTooltip(data: lsp.SignatureHelp, active: number, pos: number): Tooltip { 135 | return { 136 | pos, 137 | above: true, 138 | create: view => drawSignatureTooltip(view, data, active) 139 | } 140 | } 141 | 142 | function drawSignatureTooltip(view: EditorView, data: lsp.SignatureHelp, active: number) { 143 | let dom = document.createElement("div") 144 | dom.className = "cm-lsp-signature-tooltip" 145 | if (data.signatures.length > 1) { 146 | dom.classList.add("cm-lsp-signature-multiple") 147 | let num = dom.appendChild(document.createElement("div")) 148 | num.className = "cm-lsp-signature-num" 149 | num.textContent = `${active + 1}/${data.signatures.length}` 150 | } 151 | 152 | let signature = data.signatures[active] 153 | let sig = dom.appendChild(document.createElement("div")) 154 | sig.className = "cm-lsp-signature" 155 | let activeFrom = 0, activeTo = 0 156 | let activeN = signature.activeParameter ?? data.activeParameter 157 | let activeParam = activeN != null && signature.parameters ? signature.parameters[activeN] : null 158 | if (activeParam && Array.isArray(activeParam.label)) { 159 | ;[activeFrom, activeTo] = activeParam.label 160 | } else if (activeParam) { 161 | let found = signature.label.indexOf(activeParam.label as string) 162 | if (found > -1) { 163 | activeFrom = found 164 | activeTo = found + activeParam.label.length 165 | } 166 | } 167 | if (activeTo) { 168 | sig.appendChild(document.createTextNode(signature.label.slice(0, activeFrom))) 169 | let activeElt = sig.appendChild(document.createElement("span")) 170 | activeElt.className = "cm-lsp-active-parameter" 171 | activeElt.textContent = signature.label.slice(activeFrom, activeTo) 172 | sig.appendChild(document.createTextNode(signature.label.slice(activeTo))) 173 | } else { 174 | sig.textContent = signature.label 175 | } 176 | if (signature.documentation) { 177 | let plugin = LSPPlugin.get(view) 178 | if (plugin) { 179 | let docs = dom.appendChild(document.createElement("div")) 180 | docs.className = "cm-lsp-signature-documentation cm-lsp-documentation" 181 | docs.innerHTML = plugin.docToHTML(signature.documentation) 182 | } 183 | } 184 | return {dom} 185 | } 186 | 187 | /// Explicitly prompt the server to provide signature help at the 188 | /// cursor. 189 | export const showSignatureHelp: Command = view => { 190 | let plugin = view.plugin(signaturePlugin) 191 | if (!plugin) { 192 | view.dispatch({effects: StateEffect.appendConfig.of([signatureState, signaturePlugin])}) 193 | plugin = view.plugin(signaturePlugin) 194 | } 195 | let field = view.state.field(signatureState) 196 | if (!plugin || field === undefined) return false 197 | let lspPlugin = LSPPlugin.get(view) 198 | if (!lspPlugin) return false 199 | plugin.startRequest(lspPlugin, { 200 | triggerKind: 1 /* Invoked */, 201 | activeSignatureHelp: field ? field.data : undefined, 202 | isRetrigger: !!field 203 | }) 204 | return true 205 | } 206 | 207 | /// If there is an active signature tooltip with multiple signatures, 208 | /// move to the next one. 209 | export const nextSignature: Command = view => { 210 | let field = view.state.field(signatureState) 211 | if (!field) return false 212 | if (field.active < field.data.signatures.length - 1) 213 | view.dispatch({effects: signatureEffect.of({data: field.data, active: field.active + 1, pos: field.tooltip.pos})}) 214 | return true 215 | } 216 | 217 | /// If there is an active signature tooltip with multiple signatures, 218 | /// move to the previous signature. 219 | export const prevSignature: Command = view => { 220 | let field = view.state.field(signatureState) 221 | if (!field) return false 222 | if (field.active > 0) 223 | view.dispatch({effects: signatureEffect.of({data: field.data, active: field.active - 1, pos: field.tooltip.pos})}) 224 | return true 225 | } 226 | 227 | /// A keymap that binds 228 | /// 229 | /// - Ctrl-Shift-Space (Cmd-Shift-Space on macOS) to 230 | /// [`showSignatureHelp`](#lsp-client.showSignatureHelp) 231 | /// 232 | /// - Ctrl-Shift-ArrowUp (Cmd-Shift-ArrowUp on macOS) to 233 | /// [`prevSignature`](#lsp-client.prevSignature) 234 | /// 235 | /// - Ctrl-Shift-ArrowDown (Cmd-Shift-ArrowDown on macOS) to 236 | /// [`nextSignature`](#lsp-client.nextSignature) 237 | /// 238 | /// Note that these keys are automatically bound by 239 | /// [`signatureHelp`](#lsp-client.signatureHelp) unless you pass it 240 | /// `keymap: false`. 241 | export const signatureKeymap: readonly KeyBinding[] = [ 242 | {key: "Mod-Shift-Space", run: showSignatureHelp}, 243 | {key: "Mod-Shift-ArrowUp", run: prevSignature}, 244 | {key: "Mod-Shift-ArrowDown", run: nextSignature}, 245 | ] 246 | 247 | /// Returns an extension that enables signature help. Will bind the 248 | /// keys in [`signatureKeymap`](#lsp-client.signatureKeymap) unless 249 | /// `keymap` is set to `false`. 250 | export function signatureHelp(config: {keymap?: boolean} = {}): Extension { 251 | return [ 252 | signatureState, 253 | signaturePlugin, 254 | config.keymap === false ? [] : Prec.high(keymap.of(signatureKeymap)) 255 | ] 256 | } 257 | -------------------------------------------------------------------------------- /src/text.ts: -------------------------------------------------------------------------------- 1 | import type * as lsp from "vscode-languageserver-protocol" 2 | import {Marked} from "marked" 3 | import {EditorView} from "@codemirror/view" 4 | import {Language, language as languageFacet, highlightingFor} from "@codemirror/language" 5 | import {highlightCode, Highlighter} from "@lezer/highlight" 6 | 7 | let context: {view: EditorView, language: ((name: string) => Language | null) | undefined} | null = null 8 | 9 | export function withContext(view: EditorView, language: ((name: string) => Language | null) | undefined, f: () => T): T { 10 | let prev = context 11 | try { 12 | context = {view, language} 13 | return f() 14 | } finally { 15 | context = prev 16 | } 17 | } 18 | 19 | const marked = new Marked({ 20 | walkTokens(token) { 21 | if (!context || token.type != "code") return 22 | 23 | let lang = context.language && context.language(token.lang) 24 | if (!lang) { 25 | let viewLang = context.view.state.facet(languageFacet) 26 | if (viewLang && viewLang.name == token.lang) lang = viewLang 27 | } 28 | if (!lang) return 29 | let highlighter: Highlighter = {style: tags => highlightingFor(context!.view.state, tags)} 30 | let result = "" 31 | highlightCode(token.text, lang.parser.parse(token.text), highlighter, (text, cls) => { 32 | result += cls ? `${escHTML(text)}` : escHTML(text) 33 | }, () => { 34 | result += "
" 35 | }) 36 | token.escaped = true 37 | token.text = result 38 | } 39 | }) 40 | 41 | export function escHTML(text: string) { 42 | return text.replace(/[\n<&]/g, ch => ch == "\n" ? "
" : ch == "<" ? "<" : "&") 43 | } 44 | 45 | export function docToHTML(value: string | lsp.MarkupContent, defaultKind: lsp.MarkupKind) { 46 | let kind = defaultKind, text = value 47 | if (typeof text != "string") { 48 | kind = text.kind 49 | text = text.value 50 | } 51 | if (kind == "plaintext") { 52 | return escHTML(text) 53 | } else { 54 | return marked.parse(text, {async: false, }) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | import {EditorView} from "@codemirror/view" 2 | 3 | export const lspTheme = EditorView.baseTheme({ 4 | ".cm-lsp-documentation": { 5 | padding: "0 7px", 6 | "& p, & pre": { 7 | margin: "2px 0" 8 | } 9 | }, 10 | 11 | ".cm-lsp-signature-tooltip": { 12 | padding: "2px 6px", 13 | borderRadius: "2.5px", 14 | position: "relative", 15 | maxWidth: "30em", 16 | maxHeight: "10em", 17 | overflowY: "scroll", 18 | "& .cm-lsp-documentation": { 19 | padding: "0", 20 | fontSize: "80%", 21 | }, 22 | "& .cm-lsp-signature-num": { 23 | fontFamily: "monospace", 24 | position: "absolute", 25 | left: "2px", top: "4px", 26 | fontSize: "70%", 27 | lineHeight: "1.3" 28 | }, 29 | "& .cm-lsp-signature": { 30 | fontFamily: "monospace", 31 | textIndent: "1em hanging", 32 | }, 33 | "& .cm-lsp-active-parameter": { 34 | fontWeight: "bold" 35 | }, 36 | }, 37 | ".cm-lsp-signature-multiple": { 38 | paddingLeft: "1.5em" 39 | }, 40 | 41 | ".cm-panel.cm-lsp-rename-panel": { 42 | padding: "2px 6px 4px", 43 | position: "relative", 44 | "& label": { fontSize: "80%" }, 45 | "& [name=close]": { 46 | position: "absolute", 47 | top: "0", bottom: "0", 48 | right: "4px", 49 | backgroundColor: "inherit", 50 | border: "none", 51 | font: "inherit", 52 | padding: "0" 53 | } 54 | }, 55 | 56 | ".cm-lsp-message button[type=submit]": { 57 | display: "block" 58 | }, 59 | 60 | ".cm-lsp-reference-panel": { 61 | fontFamily: "monospace", 62 | whiteSpace: "pre", 63 | padding: "3px 6px", 64 | maxHeight: "120px", 65 | overflow: "auto", 66 | "& .cm-lsp-reference-file": { 67 | fontWeight: "bold", 68 | }, 69 | "& .cm-lsp-reference": { 70 | cursor: "pointer", 71 | "&[aria-selected]": { 72 | backgroundColor: "#0077ee44" 73 | }, 74 | }, 75 | "& .cm-lsp-reference-line": { 76 | opacity: "0.7", 77 | }, 78 | }, 79 | }) 80 | -------------------------------------------------------------------------------- /src/workspace.ts: -------------------------------------------------------------------------------- 1 | import {Text, ChangeSet, TransactionSpec} from "@codemirror/state" 2 | import {EditorView} from "@codemirror/view" 3 | import {LSPClient} from "./client" 4 | import {LSPPlugin} from "./plugin" 5 | 6 | /// A file that is open in a workspace. 7 | export interface WorkspaceFile { 8 | /// The file's unique URI. 9 | uri: string 10 | /// The LSP language ID for the file's content. 11 | languageId: string 12 | /// The current version of the file. 13 | version: number 14 | /// The document corresponding to `this.version`. Will not reflect 15 | /// changes made after that version was synchronized. Will be 16 | /// updated, along with `version`, by 17 | /// [`syncFiles`](#lsp-client.Workspace.syncFiles). 18 | doc: Text 19 | /// Get an active editor view for this file, if there is one. For 20 | /// workspaces that support multiple views on a file, `main` 21 | /// indicates a preferred view. 22 | getView(main?: EditorView): EditorView | null 23 | } 24 | 25 | interface WorkspaceFileUpdate { 26 | file: WorkspaceFile 27 | prevDoc: Text 28 | changes: ChangeSet 29 | } 30 | 31 | /// Implementing your own workspace class can provide more control 32 | /// over the way files are loaded and managed when interacting with 33 | /// the language server. See 34 | /// [`LSPClientConfig.workspace`](#lsp-client.LSPClientConfig.workspace). 35 | export abstract class Workspace { 36 | /// The files currently open in the workspace. 37 | abstract files: WorkspaceFile[] 38 | 39 | /// The constructor, as called by the client when creating a 40 | /// workspace. 41 | constructor( 42 | /// The LSP client associated with this workspace. 43 | readonly client: LSPClient 44 | ) {} 45 | 46 | /// Find the open file with the given URI, if it exists. The default 47 | /// implementation just looks it up in `this.files`. 48 | getFile(uri: string) : WorkspaceFile | null { 49 | return this.files.find(f => f.uri == uri) || null 50 | } 51 | 52 | /// Check all open files for changes (usually from editors, but they 53 | /// may also come from other sources). When a file is changed, 54 | /// return a record that describes the changes, and update the file's 55 | /// [`version`](#lsp-client.WorkspaceFile.version) and 56 | /// [`doc`](#lsp-client.WorkspaceFile.doc) properties to reflect the 57 | /// new version. 58 | abstract syncFiles(): readonly WorkspaceFileUpdate[] 59 | 60 | /// Called to request that the workspace open a file. The default 61 | /// implementation simply returns the file if it is open, null 62 | /// otherwise. 63 | requestFile(uri: string): Promise { 64 | return Promise.resolve(this.getFile(uri)) 65 | } 66 | 67 | /// Called when an editor is created for a file. The implementation 68 | /// should track the file in 69 | /// [`this.files`](#lsp-client.Workspace.files) and, if it wasn't 70 | /// open already, call 71 | /// [`LSPClient.didOpen`](#lsp-client.LSPClient.didOpen). 72 | abstract openFile(uri: string, languageId: string, view: EditorView): void 73 | 74 | /// Called when an editor holding this file is destroyed or 75 | /// reconfigured to no longer hold it. The implementation should 76 | /// track this and, when it closes the file, make sure to call 77 | /// [`LSPClient.didOpen`](#lsp-client.LSPClient.didClose). 78 | abstract closeFile(uri: string, view: EditorView): void 79 | 80 | /// Called when the client for this workspace is connected. The 81 | /// default implementation calls 82 | /// [`LSPClient.didOpen`](#lsp-client.LSPClient.didOpen) on all open 83 | /// files. 84 | connected(): void { 85 | for (let file of this.files) this.client.didOpen(file) 86 | } 87 | 88 | /// Called when the client for this workspace is disconnected. The 89 | /// default implementation does nothing. 90 | disconnected(): void {} 91 | 92 | /// Called when a server-initiated change to a file is applied. The 93 | /// default implementation simply dispatches the update to the 94 | /// file's view, if the file is open and has a view. 95 | updateFile(uri: string, update: TransactionSpec): void { 96 | let file = this.getFile(uri) 97 | if (file) file.getView()?.dispatch(update) 98 | } 99 | 100 | /// When the client needs to put a file other than the one loaded in 101 | /// the current editor in front of the user, for example in 102 | /// [`jumpToDefinition`](#lsp-client.jumpToDefinition), it will call 103 | /// this function. It should make sure to create or find an editor 104 | /// with the file and make it visible to the user, or return null if 105 | /// this isn't possible. 106 | displayFile(uri: string): Promise { 107 | let file = this.getFile(uri) 108 | return Promise.resolve(file ? file.getView() : null) 109 | } 110 | } 111 | 112 | class DefaultWorkspaceFile implements WorkspaceFile { 113 | constructor(readonly uri: string, 114 | readonly languageId: string, 115 | public version: number, 116 | public doc: Text, 117 | readonly view: EditorView) {} 118 | 119 | getView() { return this.view } 120 | } 121 | 122 | export class DefaultWorkspace extends Workspace { 123 | files: DefaultWorkspaceFile[] = [] 124 | private fileVersions: {[uri: string]: number} = Object.create(null) 125 | 126 | nextFileVersion(uri: string) { 127 | return this.fileVersions[uri] = (this.fileVersions[uri] ?? -1) + 1 128 | } 129 | 130 | syncFiles() { 131 | let result: WorkspaceFileUpdate[] = [] 132 | for (let file of this.files) { 133 | let plugin = LSPPlugin.get(file.view) 134 | if (!plugin) continue 135 | let changes = plugin.unsyncedChanges 136 | if (!changes.empty) { 137 | result.push({changes, file, prevDoc: file.doc}) 138 | file.doc = file.view.state.doc 139 | file.version = this.nextFileVersion(file.uri) 140 | plugin.clear() 141 | } 142 | } 143 | return result 144 | } 145 | 146 | openFile(uri: string, languageId: string, view: EditorView) { 147 | if (this.getFile(uri)) 148 | throw new Error("Default workspace implementation doesn't support multiple views on the same file") 149 | let file = new DefaultWorkspaceFile(uri, languageId, this.nextFileVersion(uri), view.state.doc, view) 150 | this.files.push(file) 151 | this.client.didOpen(file) 152 | } 153 | 154 | closeFile(uri: string) { 155 | let file = this.getFile(uri) 156 | if (file) { 157 | this.files = this.files.filter(f => f != file) 158 | this.client.didClose(uri) 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /test/server.ts: -------------------------------------------------------------------------------- 1 | import type * as lsp from "vscode-languageserver-protocol" 2 | import {Transport} from "@codemirror/lsp-client" 3 | 4 | const serverCapabilities: lsp.ServerCapabilities = { 5 | textDocumentSync: {openClose: true, change: 2}, 6 | renameProvider: true, 7 | documentFormattingProvider: true, 8 | completionProvider: {triggerCharacters: [","]}, 9 | hoverProvider: true, 10 | signatureHelpProvider: {}, 11 | } 12 | 13 | const requestHandlers: {[method: string]: (params: any, server: DummyServer) => any} = { 14 | "initialize": (params: lsp.InitializeParams, server): lsp.InitializeResult => { 15 | return {capabilities: serverCapabilities, serverInfo: {name: "Dummy server"}} 16 | }, 17 | 18 | "textDocument/rename": (params: lsp.RenameParams, server): lsp.WorkspaceEdit | null => { 19 | let file = server.getFile(params.textDocument.uri) 20 | if (!file) throw new ServerError(-32602, "File not open") 21 | let pos = resolvePosition(file.text, params.position) 22 | let from = pos, to = pos 23 | while (from && /\w/.test(file.text[from - 1])) from-- 24 | while (to < file.text.length && /\w/.test(file.text[to])) to++ 25 | if (from == to) return null 26 | let word = file.text.slice(from, to), changes: {[uri: string]: lsp.TextEdit[]} = {} 27 | for (let file of server.files) { 28 | let found: lsp.TextEdit[] = [], pos = 0, next 29 | while ((next = file.text.indexOf(word, pos)) > -1) { 30 | pos = next + word.length 31 | found.push({ 32 | newText: params.newName, 33 | range: {start: toPosition(file.text, next), end: toPosition(file.text, pos)} 34 | }) 35 | } 36 | if (found.length) changes[file.uri] = found 37 | } 38 | return {changes} 39 | }, 40 | 41 | "textDocument/formatting": (params: lsp.DocumentFormattingParams, server): lsp.TextEdit[] | null => { 42 | let file = server.getFile(params.textDocument.uri) 43 | if (!file) throw new ServerError(-32602, "File not open") 44 | let end = toPosition(file.text, file.text.length) 45 | return [{newText: "\n// formatted!", range: {start: end, end}}] 46 | }, 47 | 48 | "textDocument/completion": (params: lsp.CompletionParams, server): lsp.CompletionList | null => { 49 | let before = {line: params.position.line, character: params.position.character - 1} 50 | return { 51 | isIncomplete: true, 52 | itemDefaults: { 53 | editRange: {start: before, end: params.position} 54 | }, 55 | items: [ 56 | {label: "one", kind: 14, commitCharacters: ["."], textEdit: {newText: "one!", range: {start: before, end: params.position}}}, 57 | {label: "okay", kind: 7, documentation: "`code` stuff", insertText: "ookay"}, 58 | ] 59 | } 60 | }, 61 | 62 | "textDocument/hover": (params: lsp.HoverParams, server): lsp.Hover | null => { 63 | return { 64 | range: {start: params.position, end: params.position}, 65 | contents: {language: "javascript", value: "'hover'"} 66 | } 67 | }, 68 | 69 | "textDocument/signatureHelp": (params: lsp.SignatureHelpParams, server): lsp.SignatureHelp | null => { 70 | return { 71 | signatures: [{ 72 | label: "(a, b) => c", 73 | activeParameter: 1, 74 | parameters: [{label: [1, 2]}, {label: [4, 5]}] 75 | }, { 76 | label: "(x, y) => c", 77 | activeParameter: 1, 78 | parameters: [{label: [1, 2]}, {label: [4, 5]}] 79 | }], 80 | activeSignature: 0, 81 | } 82 | }, 83 | 84 | "custom/sendNotification": (params: {method: string, params: any}, server) => { 85 | server.broadcast({jsonrpc: "2.0", method: params.method, params: params.params}) 86 | }, 87 | } 88 | 89 | const notificationHandlers: {[method: string]: (params: any, server: DummyServer) => void} = { 90 | "initialized": (params: lsp.InitializedParams, server) => { 91 | server.initialized = true 92 | }, 93 | "textDocument/didOpen": (params: lsp.DidOpenTextDocumentParams, server) => { 94 | let {uri, text, languageId} = params.textDocument 95 | if (!server.getFile(params.textDocument.uri)) 96 | server.files.push(new OpenFile(uri, languageId, text)) 97 | }, 98 | "textDocument/didClose": (params: lsp.DidCloseTextDocumentParams, server) => { 99 | server.files = server.files.filter(f => f.uri != params.textDocument.uri) 100 | }, 101 | "textDocument/didChange": (params: lsp.DidChangeTextDocumentParams, server) => { 102 | let file = server.getFile(params.textDocument.uri) 103 | if (file) for (let ch of params.contentChanges) { 104 | if ("range" in ch) 105 | file.text = file.text.slice(0, resolvePosition(file.text, ch.range.start)) + ch.text + 106 | file.text.slice(resolvePosition(file.text, ch.range.end)) 107 | else 108 | file.text = ch.text 109 | } 110 | } 111 | } 112 | 113 | function resolvePosition(text: string, pos: lsp.Position) { 114 | let line = 0, off = 0 115 | while (line < pos.line) { 116 | let next = text.indexOf("\n", off) 117 | if (!next) throw new RangeError("Position out of bounds") 118 | off = next + 1 119 | line++ 120 | } 121 | off += pos.character 122 | if (off > text.length) throw new RangeError("Position out of bounds") 123 | return off 124 | } 125 | 126 | function toPosition(text: string, pos: number): lsp.Position { 127 | for (let off = 0, line = 0;;) { 128 | let next = text.indexOf("\n", off) 129 | if (next < 0 || next >= pos) return {line, character: pos - off} 130 | off = next + 1 131 | line++ 132 | } 133 | } 134 | 135 | class ServerError extends Error { 136 | constructor(readonly code: number, message: string) { super(message) } 137 | } 138 | 139 | class OpenFile { 140 | constructor(readonly uri: string, readonly languageId: string, public text: string) {} 141 | } 142 | 143 | export class DummyServer implements Transport { 144 | initialized = false 145 | subscribers: ((msg: string) => void)[] = [] 146 | files: OpenFile[] = [] 147 | 148 | constructor(readonly config: { 149 | delay?: {[method: string]: number}, 150 | brokenPipe?: () => boolean 151 | } = {}) { 152 | } 153 | 154 | subscribe(listener: (msg: string) => void) { 155 | this.subscribers.push(listener) 156 | } 157 | 158 | unsubscribe(listener: (msg: string) => void) { 159 | this.subscribers = this.subscribers.filter(l => l != listener) 160 | } 161 | 162 | send(message: string) { 163 | if (this.config.brokenPipe?.()) throw new Error("Broken Pipe") 164 | const msg = JSON.parse(message) as lsp.RequestMessage | lsp.NotificationMessage 165 | if ("id" in msg) { 166 | this.handleRequest(msg.method, msg.params).then(result => { 167 | this.broadcast({jsonrpc: "2.0", id: msg.id, result}) 168 | }, e => { 169 | let error = e instanceof ServerError ? {code: e.code, message: e.message} 170 | : {code: -32603 /* InternalError */, message: String(e)} 171 | this.broadcast({jsonrpc: "2.0", id: msg.id, error}) 172 | }) 173 | } else { 174 | this.handleNotification(msg.method, msg.params) 175 | } 176 | } 177 | 178 | broadcast(message: any) { 179 | for (let sub of this.subscribers) sub(JSON.stringify(message)) 180 | } 181 | 182 | handleRequest(method: string, params: Params): Promise { 183 | return new Promise(resolve => { 184 | if (!this.initialized && method != "initialize") 185 | throw new ServerError(-32002 /* ServerNotInitialized */, "Not initialized") 186 | let handler = requestHandlers[method] 187 | if (!handler) throw new ServerError(-32601 /* MethodNotFound */, "Method not found") 188 | let result = handler(params, this), delay = this.config.delay?.[method] 189 | if (delay) setTimeout(() => resolve(result)) 190 | else queueMicrotask(() => resolve(result)) 191 | }) 192 | } 193 | 194 | handleNotification(method: string, params: Params) { 195 | let handler = notificationHandlers[method] 196 | if (handler) handler(params, this) 197 | } 198 | 199 | getFile(uri: string) { return this.files.find(f => f.uri == uri) } 200 | } 201 | -------------------------------------------------------------------------------- /test/webtest-client.ts: -------------------------------------------------------------------------------- 1 | import type * as lsp from "vscode-languageserver-protocol" 2 | import ist from "ist" 3 | import {LSPClientConfig, LSPClient, LSPPlugin, renameSymbol, 4 | formatDocument, serverCompletion, hoverTooltips, 5 | showSignatureHelp, nextSignature} from "@codemirror/lsp-client" 6 | import {EditorView, EditorViewConfig} from "@codemirror/view" 7 | import {javascript} from "@codemirror/lang-javascript" 8 | import {syntaxHighlighting} from "@codemirror/language" 9 | import {startCompletion, currentCompletions, acceptCompletion, autocompletion, moveCompletionSelection} from "@codemirror/autocomplete" 10 | import {classHighlighter} from "@lezer/highlight" 11 | import {DummyServer} from "./server.js" 12 | 13 | function setup(conf: {client?: LSPClientConfig, server?: ConstructorParameters[0]} = {}) { 14 | let server = new DummyServer(conf.server) 15 | let client = new LSPClient(conf.client) 16 | client.connect(server) 17 | return {server, client} 18 | } 19 | 20 | const URI = "file:///home/holly/src/test.js" 21 | 22 | function ed(client: LSPClient, conf: EditorViewConfig, uri = URI) { 23 | return new EditorView({...conf, extensions: [conf.extensions || [], LSPPlugin.create(client, uri, "javascript")]}) 24 | } 25 | 26 | function sync(cm: EditorView) { 27 | LSPPlugin.get(cm)!.client.sync() 28 | } 29 | 30 | function wait(ms: number = 2) { 31 | return new Promise(resolve => setTimeout(resolve, ms)) 32 | } 33 | 34 | function place(cm: EditorView) { 35 | let ws = document.querySelector("#workspace")! 36 | while (ws.firstChild) ws.firstChild.remove() 37 | ws.appendChild(cm.dom) 38 | setTimeout(() => cm.destroy(), 1000) 39 | return cm 40 | } 41 | 42 | describe("LSPClient", () => { 43 | it("can connect to a server", () => { 44 | let {client} = setup() 45 | return client.initializing 46 | }) 47 | 48 | it("can open a file", async () => { 49 | let {client, server} = setup() 50 | ed(client, {doc: "stitchwort"}) 51 | await wait() 52 | ist(server.getFile(URI)!.text, "stitchwort") 53 | }) 54 | 55 | it("can update a file", async () => { 56 | let {client, server} = setup() 57 | let cm = ed(client, {doc: "goldenrod"}) 58 | cm.dispatch({changes: {from: 1, insert: "-"}}) 59 | sync(cm) 60 | await wait() 61 | ist(server.getFile(URI)!.text, cm.state.sliceDoc()) 62 | }) 63 | 64 | it("can update a file with multiple changes", async () => { 65 | let {client, server} = setup() 66 | let cm = ed(client, {doc: "hawkweed\n".repeat(1000)}) 67 | await wait() 68 | cm.dispatch({changes: [{from: 0, insert: "<"}, {from: cm.state.doc.length, insert: ">"}]}) 69 | sync(cm) 70 | await wait() 71 | ist(server.getFile(URI)!.text, cm.state.sliceDoc()) 72 | }) 73 | 74 | it("can close a file", async () => { 75 | let {client, server} = setup() 76 | let cm = ed(client, {doc: "cowleek"}) 77 | await wait() 78 | cm.destroy() 79 | await wait() 80 | ist(!server.getFile(URI)) 81 | }) 82 | 83 | it("can open multiple files", async () => { 84 | let {client, server} = setup() 85 | let cm1 = ed(client, {doc: "elder"}), cm2 = ed(client, {doc: "alfalfa"}, "file:///x") 86 | cm1.dispatch({changes: {from: 5, insert: "?"}}) 87 | cm2.dispatch({changes: {from: 7, insert: "!"}}) 88 | sync(cm1) 89 | await wait() 90 | ist(server.getFile(URI)!.text, "elder?") 91 | ist(server.getFile("file:///x")!.text, "alfalfa!") 92 | }) 93 | 94 | it("can provide mapping", async () => { 95 | let {client} = setup() 96 | let cm = ed(client, {doc: "1\n2\n3"}) 97 | await client.withMapping(async mapping => { 98 | let req = client.request("textDocument/formatting", { 99 | textDocument: {uri: URI}, 100 | options: {tabSize: 2, insertSpaces: true} 101 | }) 102 | cm.dispatch({changes: {from: 1, to: 3}}) 103 | let response = await req 104 | ist(response![0].range.start.line, 2) 105 | ist(mapping.mapPos(URI, 5), 3) 106 | }) 107 | }) 108 | 109 | it("can provide mapping across syncs", async () => { 110 | let {client} = setup({server: {delay: {"textDocument/formatting": 10}}}) 111 | let cm = ed(client, {doc: "1\n2\n3"}) 112 | await client.withMapping(async mapping => { 113 | let req = client.request("textDocument/formatting", { 114 | textDocument: {uri: URI}, 115 | options: {tabSize: 2, insertSpaces: true} 116 | }) 117 | cm.dispatch({changes: {from: 1, to: 3}}) 118 | sync(cm) 119 | cm.dispatch({changes: {from: 0, insert: "#"}}) 120 | let response = await req 121 | ist(response![0].range.start.line, 2) 122 | ist(mapping.mapPos(URI, 5), 4) 123 | }) 124 | }) 125 | 126 | it("reports invalid methods", async () => { 127 | let {client} = setup() 128 | try { 129 | await client.request("none/such", {}) 130 | ist(false) 131 | } catch (e: any) { 132 | ist(e.code, -32601) 133 | } 134 | }) 135 | 136 | it("can receive custom notifications", async () => { 137 | let received: any = null 138 | let {client} = setup({client: {notificationHandlers: { 139 | "custom/notification": (client, params) => received = params 140 | }}}) 141 | client.request("custom/sendNotification", {method: "custom/notification", params: {verify: true}}) 142 | await wait() 143 | ist(received.verify) 144 | }) 145 | 146 | it("can report unknown notifications", async () => { 147 | let received: any = null 148 | let {client} = setup({client: {unhandledNotification: (client, method, params) => received = params}}) 149 | client.request("custom/sendNotification", {method: "custom/notification", params: {verify: true}}) 150 | await wait() 151 | ist(received.verify) 152 | }) 153 | 154 | it("can display messages in the editor", async () => { 155 | let {client} = setup() 156 | let cm = ed(client, {}) 157 | client.request("custom/sendNotification", {method: "window/showMessage", params: {type: 2 /* Warning */, message: "WARNING"}}) 158 | await wait() 159 | let dialog = cm.dom.querySelector(".cm-lsp-message-warning")! 160 | ist(dialog) 161 | ist(dialog.innerHTML.indexOf("WARNING"), "-1", ">") 162 | }) 163 | 164 | it("routes exceptions from Transport.send to the request promise", async () => { 165 | let broken = false 166 | let {client} = setup({server: {brokenPipe: () => broken}}) 167 | await client.initializing 168 | broken = true 169 | let req = client.request("test", {}) 170 | try { 171 | await req 172 | ist(false) 173 | } catch (e: any) { 174 | ist(e.message, "Broken Pipe") 175 | } 176 | }) 177 | 178 | describe("LSPPlugin", () => { 179 | it("can render doc strings", () => { 180 | let {client} = setup({client: {sanitizeHTML: s => s.replace(/x/g, "y")}}) 181 | let cm = ed(client, {}) 182 | ist(LSPPlugin.get(cm)!.docToHTML({kind: "markdown", value: "# xx"}), "

yy

\n") 183 | }) 184 | 185 | it("can render doc strings with highlighting", () => { 186 | let {client} = setup() 187 | let cm = ed(client, {extensions: [ 188 | javascript(), 189 | syntaxHighlighting(classHighlighter) 190 | ]}) 191 | ist(LSPPlugin.get(cm)!.docToHTML({kind: "markdown", value: "```javascript\nreturn\n```"}), 192 | '
return\n
\n') 193 | }) 194 | 195 | it("can convert to LSP positions", () => { 196 | let {client} = setup() 197 | let cm = ed(client, {doc: "one\ntwo\nthree"}) 198 | let pos = LSPPlugin.get(cm)!.toPosition(6) 199 | ist(pos.line, 1) 200 | ist(pos.character, 2) 201 | }) 202 | 203 | it("can convert from positions", () => { 204 | let {client} = setup() 205 | let cm = ed(client, {doc: "one\ntwo\nthree"}) 206 | ist(LSPPlugin.get(cm)!.fromPosition({line: 0, character: 3}), 3) 207 | ist(LSPPlugin.get(cm)!.fromPosition({line: 2, character: 1}), 9) 208 | }) 209 | 210 | it("can display errors", () => { 211 | let {client} = setup() 212 | let cm = ed(client, {}) 213 | LSPPlugin.get(cm)!.reportError("E", "Oh no") 214 | ist(cm.dom.querySelector(".cm-lsp-message-error")!.innerHTML.indexOf("Oh no"), -1, ">") 215 | }) 216 | }) 217 | 218 | describe("renameSymbol", () => { 219 | it("can run a rename", async () => { 220 | let {client} = setup() 221 | let cm = place(ed(client, {doc: "let foo = 1; console.log(foo)", selection: {anchor: 4}})) 222 | let cm2 = ed(client, {doc: "foo?"}, "file:///2") 223 | await wait() 224 | ist(renameSymbol(cm), true) 225 | let form = cm.dom.querySelector(".cm-panel form") as HTMLFormElement 226 | form.querySelector("input")!.value = "bar" 227 | form.requestSubmit() 228 | await wait() 229 | ist(cm.state.sliceDoc(), "let bar = 1; console.log(bar)") 230 | ist(cm2.state.sliceDoc(), "bar?") 231 | cm.destroy() 232 | }) 233 | 234 | it("can handle changes during the request", async () => { 235 | let {client} = setup({server: {delay: {"textDocument/rename": 5}}}) 236 | let cm = place(ed(client, {doc: "let foo = 1; console.log(foo)", selection: {anchor: 4}})) 237 | await wait() 238 | ist(renameSymbol(cm), true) 239 | let form = cm.dom.querySelector(".cm-panel form") as HTMLFormElement 240 | form.querySelector("input")!.value = "bar" 241 | form.requestSubmit() 242 | await wait() 243 | cm.dispatch({changes: {from: 0, insert: " "}}) 244 | await wait(10) 245 | ist(cm.state.sliceDoc(), " let bar = 1; console.log(bar)") 246 | cm.destroy() 247 | }) 248 | }) 249 | 250 | describe("formatDocument", () => { 251 | it("can make format requests", async () => { 252 | let {client} = setup() 253 | let cm = ed(client, {doc: "hawthorn"}) 254 | formatDocument(cm) 255 | await wait() 256 | ist(cm.state.sliceDoc(), "hawthorn\n// formatted!") 257 | }) 258 | }) 259 | 260 | describe("completion", () => { 261 | it("can get completions from the server", async () => { 262 | let {client} = setup() 263 | let cm = ed(client, {doc: "..o", selection: {anchor: 3}, extensions: [ 264 | serverCompletion(), 265 | autocompletion({interactionDelay: 0, activateOnTypingDelay: 10}) 266 | ]}) 267 | startCompletion(cm) 268 | await wait(60) 269 | let cs = currentCompletions(cm.state) 270 | ist(cs.length, 2) 271 | ist(cs[0].label, "one!") 272 | ist(cs[1].label, "ookay") 273 | acceptCompletion(cm) 274 | ist(cm.state.sliceDoc(), "..one!") 275 | cm.dispatch({changes: {from: 6, insert: "\no"}, userEvent: "input.type", selection: {anchor: 8}}) 276 | await wait(20) 277 | ist(currentCompletions(cm.state).length, 2) 278 | moveCompletionSelection(true)(cm) 279 | await wait() 280 | ist(cm.dom.querySelector(".cm-completionInfo")) 281 | acceptCompletion(cm) 282 | ist(cm.state.sliceDoc(), "..one!\nookay") 283 | }) 284 | }) 285 | 286 | describe("hoverTooltips", () => { 287 | it("can retrieve hover info", async () => { 288 | let {client} = setup() 289 | let cm = place(ed(client, {doc: "speedwell", extensions: [ 290 | hoverTooltips({hoverTime: 10}), 291 | javascript(), 292 | syntaxHighlighting(classHighlighter) 293 | ]})) 294 | let pos = cm.coordsAtPos(1)!, x = pos.left, y = pos.top + 5 295 | cm.contentDOM.firstChild!.dispatchEvent(new MouseEvent("mousemove", { 296 | screenX: x, screenY: y, 297 | clientX: x, clientY: y, 298 | bubbles: true 299 | })) 300 | await wait(15) 301 | ist(cm.dom.querySelector(".cm-tooltip .tok-string")) 302 | cm.destroy() 303 | }) 304 | }) 305 | 306 | describe("signatureHelp", () => { 307 | it("can display a signature", async () => { 308 | let {client} = setup() 309 | let cm = ed(client, {doc: "bugloss"}) 310 | showSignatureHelp(cm) 311 | await wait() 312 | ist(cm.dom.querySelector(".cm-lsp-active-parameter")!.innerHTML, "b") 313 | nextSignature(cm) 314 | ist(cm.dom.querySelector(".cm-lsp-active-parameter")!.innerHTML, "y") 315 | }) 316 | }) 317 | }) 318 | --------------------------------------------------------------------------------