Skip to content

Commit 5cfe07c

Browse files
authored
Merge pull request #124 from atom-community/spawn
2 parents b013c51 + 3c29170 commit 5cfe07c

File tree

6 files changed

+196
-47
lines changed

6 files changed

+196
-47
lines changed

README.md

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ Provide integration support for adding Language Server Protocol servers to Atom.
1515
This npm package can be used by Atom package authors wanting to integrate LSP-compatible language servers with Atom. It provides:
1616

1717
* Conversion routines between Atom and LSP types
18-
* A FlowTyped wrapper around JSON-RPC for **v3** of the LSP protocol
19-
* All necessary FlowTyped input and return structures for LSP, notifications etc.
18+
* A TypeScript wrapper around JSON-RPC for **v3** of the LSP protocol
19+
* All necessary TypeScript input and return structures for LSP, notifications etc.
2020
* A number of adapters to translate communication between Atom/Atom-IDE and the LSP's capabilities
2121
* Automatic wiring up of adapters based on the negotiated capabilities of the language server
2222
* Helper functions for downloading additional non-npm dependencies
@@ -59,9 +59,9 @@ The language server protocol consists of a number of capabilities. Some of these
5959

6060
The underlying JSON-RPC communication is handled by the [vscode-jsonrpc npm module](https://www.npmjs.com/package/vscode-jsonrpc).
6161

62-
### Minimal example
62+
### Minimal example (Nodejs-compatible LSP exe)
6363

64-
A minimal implementation can be illustrated by the Omnisharp package here which has only npm-managed dependencies. You simply provide the scope name, language name and server name as well as start your process and AutoLanguageClient takes care of interrogating your language server capabilities and wiring up the appropriate services within Atom to expose them.
64+
A minimal implementation can be illustrated by the Omnisharp package here which has only npm-managed dependencies, and the LSP is a JavaScript file. You simply provide the scope name, language name and server name as well as start your process and `AutoLanguageClient` takes care of interrogating your language server capabilities and wiring up the appropriate services within Atom to expose them.
6565

6666
```javascript
6767
const {AutoLanguageClient} = require('atom-languageclient')
@@ -83,6 +83,32 @@ You can get this code packaged up with the necessary package.json etc. from the
8383

8484
Note that you will also need to add various entries to the `providedServices` and `consumedServices` section of your package.json (for now). You can [obtain these entries here](https://github.com/atom/ide-csharp/tree/master/package.json).
8585

86+
### Minimal example (General LSP exe)
87+
88+
If the LSP is a general executable (not a JavaScript file), you should use `spawn` inside `startServerProcess`.
89+
90+
```javascript
91+
const {AutoLanguageClient} = require('atom-languageclient')
92+
93+
class DLanguageClient extends AutoLanguageClient {
94+
getGrammarScopes () { return [ 'source.d' ] }
95+
getLanguageName () { return 'D' }
96+
getServerName () { return 'serve-d' }
97+
98+
startServerProcess (projectPath) {
99+
return super.spawn(
100+
'serve-d', // the `name` or `path` of the executable
101+
// if the `name` is provided it checks `bin/platform-arch/exeName` by default, and if doesn't exists uses the `exeName` on the PATH
102+
[], // args passed to spawn the exe
103+
{ cwd: projectPath } // child process spawn options
104+
)
105+
}
106+
}
107+
108+
module.exports = new DLanguageClient()
109+
```
110+
111+
86112
### Using other connection types
87113

88114
The default connection type is *stdio* however both *ipc* and *sockets* are also available.

lib/auto-languageclient.ts

Lines changed: 94 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,9 @@ export default class AutoLanguageClient {
117117
}
118118

119119
/** (Optional) Return the parameters used to initialize a client - you may want to extend capabilities */
120-
protected getInitializeParams(projectPath: string, process: LanguageServerProcess): ls.InitializeParams {
120+
protected getInitializeParams(projectPath: string, lsProcess: LanguageServerProcess): ls.InitializeParams {
121121
return {
122-
processId: process.pid,
122+
processId: lsProcess.pid,
123123
rootPath: projectPath,
124124
rootUri: Convert.pathToUri(projectPath),
125125
workspaceFolders: null,
@@ -279,7 +279,34 @@ export default class AutoLanguageClient {
279279
await this._serverManager.stopAllServers();
280280
}
281281

282-
protected spawnChildNode(args: string[], options: cp.SpawnOptions = {}): cp.ChildProcess {
282+
/**
283+
* Spawn a general language server.
284+
* Use this inside the `startServerProcess` override if the language server is a general executable.
285+
* Also see the `spawnChildNode` method.
286+
* If the name is provided as the first argument, it checks `bin/platform-arch/exeName` by default, and if doesn't exists uses the exe on PATH.
287+
* For example on Windows x64, by passing `serve-d`, `bin/win32-x64/exeName.exe` is spawned by default.
288+
* @param exe the `name` or `path` of the executable
289+
* @param args args passed to spawn the exe. Defaults to `[]`.
290+
* @param options: child process spawn options. Defaults to `{}`.
291+
* @param rootPath the path of the folder of the exe file. Defaults to `join("bin", `${process.platform}-${process.arch}`)`.
292+
* @param exeExtention the extention of the exe file. Defaults to `process.platform === "win32" ? ".exe" : ""`
293+
*/
294+
protected spawn(
295+
exe: string,
296+
args: string[] = [],
297+
options: cp.SpawnOptions = {},
298+
rootPath = Utils.rootPathDefault,
299+
exeExtention = Utils.exeExtentionDefault
300+
): LanguageServerProcess {
301+
this.logger.debug(`starting "${exe} ${args.join(' ')}"`);
302+
return cp.spawn(Utils.getExePath(exe, rootPath, exeExtention), args, options);
303+
}
304+
305+
/** Spawn a language server using Atom's Nodejs process
306+
* Use this inside the `startServerProcess` override if the language server is a JavaScript file.
307+
* Also see the `spawn` method
308+
*/
309+
protected spawnChildNode(args: string[], options: cp.SpawnOptions = {}): LanguageServerProcess {
283310
this.logger.debug(`starting child Node "${args.join(' ')}"`);
284311
options.env = options.env || Object.create(process.env);
285312
if (options.env) {
@@ -299,14 +326,14 @@ export default class AutoLanguageClient {
299326

300327
/** Starts the server by starting the process, then initializing the language server and starting adapters */
301328
private async startServer(projectPath: string): Promise<ActiveServer> {
302-
const process = await this.reportBusyWhile(
329+
const lsProcess = await this.reportBusyWhile(
303330
`Starting ${this.getServerName()} for ${path.basename(projectPath)}`,
304331
async () => this.startServerProcess(projectPath),
305332
);
306-
this.captureServerErrors(process, projectPath);
307-
const connection = new LanguageClientConnection(this.createRpcConnection(process), this.logger);
333+
this.captureServerErrors(lsProcess, projectPath);
334+
const connection = new LanguageClientConnection(this.createRpcConnection(lsProcess), this.logger);
308335
this.preInitialization(connection);
309-
const initializeParams = this.getInitializeParams(projectPath, process);
336+
const initializeParams = this.getInitializeParams(projectPath, lsProcess);
310337
const initialization = connection.initialize(initializeParams);
311338
this.reportBusyWhile(
312339
`${this.getServerName()} initializing for ${path.basename(projectPath)}`,
@@ -315,7 +342,7 @@ export default class AutoLanguageClient {
315342
const initializeResponse = await initialization;
316343
const newServer = {
317344
projectPath,
318-
process,
345+
process: lsProcess,
319346
connection,
320347
capabilities: initializeResponse.capabilities,
321348
disposable: new CompositeDisposable(),
@@ -353,22 +380,19 @@ export default class AutoLanguageClient {
353380
return newServer;
354381
}
355382

356-
private captureServerErrors(childProcess: LanguageServerProcess, projectPath: string): void {
357-
childProcess.on('error', (err) => this.handleSpawnFailure(err));
358-
childProcess.on('exit', (code, signal) => this.logger.debug(`exit: code ${code} signal ${signal}`));
359-
childProcess.stderr.setEncoding('utf8');
360-
childProcess.stderr.on('data', (chunk: Buffer) => {
361-
const errorString = chunk.toString();
362-
this.handleServerStderr(errorString, projectPath);
363-
// Keep the last 5 lines for packages to use in messages
364-
this.processStdErr = (this.processStdErr + errorString)
365-
.split('\n')
366-
.slice(-5)
367-
.join('\n');
368-
});
383+
private captureServerErrors(lsProcess: LanguageServerProcess, projectPath: string): void {
384+
lsProcess.on('error', (err) => this.onSpawnError(err));
385+
lsProcess.on("close", (code, signal) => this.onSpawnClose(code, signal));
386+
lsProcess.on("disconnect", () => this.onSpawnDisconnect());
387+
lsProcess.on('exit', (code, signal) => this.onSpawnExit(code, signal));
388+
lsProcess.stderr?.setEncoding('utf8');
389+
lsProcess.stderr?.on('data', (chunk: Buffer) => this.onSpawnStdErrData(chunk, projectPath));
369390
}
370391

371-
private handleSpawnFailure(err: any): void {
392+
/** The function called whenever the spawned server `error`s.
393+
* Extend (call super.onSpawnError) or override this if you need custom error handling
394+
*/
395+
protected onSpawnError(err: Error): void {
372396
atom.notifications.addError(
373397
`${this.getServerName()} language server for ${this.getLanguageName()} unable to start`,
374398
{
@@ -378,23 +402,66 @@ export default class AutoLanguageClient {
378402
);
379403
}
380404

405+
/** The function called whenever the spawned server `close`s.
406+
* Extend (call super.onSpawnClose) or override this if you need custom close handling
407+
*/
408+
protected onSpawnClose(code: number | null, signal: NodeJS.Signals | null): void {
409+
if (code !== 0 && signal === null) {
410+
atom.notifications.addError(
411+
`${this.getServerName()} language server for ${this.getLanguageName()} was closed with code: ${code}.`
412+
);
413+
}
414+
}
415+
416+
/** The function called whenever the spawned server `disconnect`s.
417+
* Extend (call super.onSpawnDisconnect) or override this if you need custom disconnect handling
418+
*/
419+
protected onSpawnDisconnect(): void {
420+
this.logger.debug(`${this.getServerName()} language server for ${this.getLanguageName()} got disconnected.`);
421+
}
422+
423+
/** The function called whenever the spawned server `exit`s.
424+
* Extend (call super.onSpawnExit) or override this if you need custom exit handling
425+
*/
426+
protected onSpawnExit(code: number | null, signal: NodeJS.Signals | null): void {
427+
this.logger.debug(`exit: code ${code} signal ${signal}`);
428+
}
429+
430+
/** The function called whenever the spawned server returns `data` in `stderr`
431+
* Extend (call super.onSpawnStdErrData) or override this if you need custom stderr data handling
432+
*/
433+
protected onSpawnStdErrData(chunk: Buffer, projectPath: string): void {
434+
const errorString = chunk.toString();
435+
this.handleServerStderr(errorString, projectPath);
436+
// Keep the last 5 lines for packages to use in messages
437+
this.processStdErr = (this.processStdErr + errorString)
438+
.split('\n')
439+
.slice(-5)
440+
.join('\n');
441+
}
442+
381443
/** Creates the RPC connection which can be ipc, socket or stdio */
382-
private createRpcConnection(process: LanguageServerProcess): rpc.MessageConnection {
444+
private createRpcConnection(lsProcess: LanguageServerProcess): rpc.MessageConnection {
383445
let reader: rpc.MessageReader;
384446
let writer: rpc.MessageWriter;
385447
const connectionType = this.getConnectionType();
386448
switch (connectionType) {
387449
case 'ipc':
388-
reader = new rpc.IPCMessageReader(process as cp.ChildProcess);
389-
writer = new rpc.IPCMessageWriter(process as cp.ChildProcess);
450+
reader = new rpc.IPCMessageReader(lsProcess as cp.ChildProcess);
451+
writer = new rpc.IPCMessageWriter(lsProcess as cp.ChildProcess);
390452
break;
391453
case 'socket':
392454
reader = new rpc.SocketMessageReader(this.socket);
393455
writer = new rpc.SocketMessageWriter(this.socket);
394456
break;
395457
case 'stdio':
396-
reader = new rpc.StreamMessageReader(process.stdout);
397-
writer = new rpc.StreamMessageWriter(process.stdin);
458+
if (lsProcess.stdin !== null && lsProcess.stdout !== null) {
459+
reader = new rpc.StreamMessageReader(lsProcess.stdout);
460+
writer = new rpc.StreamMessageWriter(lsProcess.stdin);
461+
} else {
462+
this.logger.error(`The language server process for ${this.getLanguageName()} does not have a valid stdin and stdout`);
463+
return Utils.assertUnreachable('stdio' as never)
464+
}
398465
break;
399466
default:
400467
return Utils.assertUnreachable(connectionType);

lib/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Logger, ConsoleLogger, FilteredLogger } from './logger';
99
import DownloadFile from './download-file';
1010
import LinterPushV2Adapter from './adapters/linter-push-v2-adapter';
1111
import CommandExecutionAdapter from './adapters/command-execution-adapter';
12+
export { getExePath } from "./utils"
1213

1314
export * from './auto-languageclient';
1415
export {

lib/server-manager.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import Convert from './convert';
22
import * as path from 'path';
3-
import * as stream from 'stream';
43
import * as ls from './languageclient';
5-
import { EventEmitter } from 'events';
4+
import { ChildProcess } from 'child_process'
65
import { Logger } from './logger';
76
import {
87
CompositeDisposable,
@@ -11,22 +10,16 @@ import {
1110
} from 'atom';
1211
import { ReportBusyWhile } from './utils';
1312

13+
14+
type MinimalLanguageServerProcess = Pick<ChildProcess, 'stdin' | 'stdout' | 'stderr' | 'pid' | 'kill' | 'on'>
15+
1416
/**
15-
* Public: Defines the minimum surface area for an object that resembles a
16-
* ChildProcess. This is used so that language packages with alternative
17-
* language server process hosting strategies can return something compatible
18-
* with AutoLanguageClient.startServerProcess.
17+
* Public: Defines a language server process which is either a ChildProcess, or it is a minimal object that resembles a
18+
* ChildProcess.
19+
* `MinimalLanguageServerProcess` is used so that language packages with alternative language server process hosting strategies
20+
* can return something compatible with `AutoLanguageClient.startServerProcess`.
1921
*/
20-
export interface LanguageServerProcess extends EventEmitter {
21-
stdin: stream.Writable;
22-
stdout: stream.Readable;
23-
stderr: stream.Readable;
24-
pid: number;
25-
26-
kill(signal?: NodeJS.Signals | number): void;
27-
on(event: 'error', listener: (err: Error) => void): this;
28-
on(event: 'exit', listener: (code: number, signal: NodeJS.Signals | number) => void): this;
29-
}
22+
export type LanguageServerProcess = ChildProcess | MinimalLanguageServerProcess
3023

3124
/** The necessary elements for a server that has started or is starting. */
3225
export interface ActiveServer {

lib/utils.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { join, resolve } from 'path';
2+
import { existsSync } from 'fs';
13
import {
24
Point,
35
TextBuffer,
@@ -111,3 +113,24 @@ export function promiseWithTimeout<T>(ms: number, promise: Promise<T>): Promise<
111113
});
112114
});
113115
}
116+
117+
118+
export const rootPathDefault = join("bin", `${process.platform}-${process.arch}`);
119+
export const exeExtentionDefault = process.platform === "win32" ? ".exe" : "";
120+
121+
/** Finds an exe file in the package assuming it is placed under `rootPath/platform-arch/exe`. If the exe file did not exist,
122+
* the given name is returned.
123+
* For example on Windows x64, if the `exeName` is `serve-d`, it returns the absolute path to `./bin/win32-x64/exeName.exe`, and
124+
* if the file did not exist, `serve-d` is returned.
125+
* @param exeName name of the exe file
126+
* @param rootPath the path of the folder of the exe file. Defaults to 'join("bin", `${process.platform}-${process.arch}`)'
127+
* @param exeExtention the extention of the exe file. Defaults to `process.platform === "win32" ? ".exe" : ""`
128+
*/
129+
export function getExePath(exeName: string, rootPath = rootPathDefault, exeExtention = exeExtentionDefault): string {
130+
const exePath = resolve(join(rootPath, `${exeName}${exeExtention}`));
131+
if (existsSync(exePath)) {
132+
return exePath
133+
} else {
134+
return exeName
135+
}
136+
}

test/utils.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import * as Utils from '../lib/utils';
22
import { createFakeEditor } from './helpers';
33
import { expect } from 'chai';
44
import { Point } from 'atom';
5+
import { join } from 'path'
6+
import * as fs from 'fs'
7+
import * as sinon from 'sinon'
58

69
describe('Utils', () => {
710
describe('getWordAtPosition', () => {
@@ -30,4 +33,40 @@ describe('Utils', () => {
3033
expect(range.serialize()).eql([[0, 4], [0, 4]]);
3134
});
3235
});
36+
37+
describe('getExePath', () => {
38+
it('returns the exe path under bin folder by default', () => {
39+
let expectedExe = join(process.cwd(), 'bin', `${process.platform}-${process.arch}`, 'serve-d');
40+
if (process.platform === 'win32') {
41+
expectedExe = expectedExe + '.exe';
42+
}
43+
44+
const fsMock = sinon.mock(fs);
45+
fsMock.expects('existsSync').withArgs(expectedExe).returns(true);
46+
47+
const exePath = Utils.getExePath('serve-d');
48+
expect(exePath).eq(expectedExe);
49+
50+
fsMock.restore();
51+
})
52+
it('returns the exe path for the given root', () => {
53+
const rootPath = join(__dirname, `${process.platform}-${process.arch}`);
54+
let expectedExe = join(rootPath, 'serve-d');
55+
if (process.platform === 'win32') {
56+
expectedExe = expectedExe + '.exe';
57+
}
58+
59+
const fsMock = sinon.mock(fs);
60+
fsMock.expects('existsSync').withArgs(expectedExe).returns(true);
61+
62+
const exePath = Utils.getExePath('serve-d', rootPath);
63+
expect(exePath).eq(expectedExe);
64+
65+
fsMock.restore();
66+
})
67+
it('returns the exe name if the file does not exist under rootPath', () => {
68+
const exePath = Utils.getExePath('python');
69+
expect(exePath).eq('python');
70+
})
71+
})
3372
});

0 commit comments

Comments
 (0)