diff --git a/README.md b/README.md index 8109427..ffc76e9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,47 @@ -# Newtan-LSP +# Newton LSP Server Bridge -Newtan LSP service \ No newline at end of file +## Overview +This script provides a WebSocket bridge to interface with multiple Language Servers through clients via a single WebSocket connection. +It supports various language servers concurrently and handles language-specific file extensions and initialization parameters. + +## Features +- Utilizes `vscode-jsonrpc` for LSP message handling. +- Supports multiple language servers concurrently. +- Support for IPC and stdio communication with language servers. +- Easy language server configuration and initialization parameter modification. +- Error handling and logging for language server processes. +- Automatic file URI translation between server and client paths. +- Temporary file creation for `textDocument/didOpen` events. + +## Usage + +1. Install Node.js. +2. Run 'npm install'. +3. Configure the desired language servers in `defaultServers.js`. +4. Run the script: `npm run start || start-verbose`. +5. Connect to the WebSocket server on `ws://localhost:9999/`. +6. Transmit LSP messages via the WebSocket connection. + +## Configuration + +Add or modify language server configurations in `languageServers.js`: + +```javascript +exports.servers = [ + { + endpointName: "language_endpoint", // WebSocket endpoint name + args: ['executable_path', ['arg1', 'arg2', ...]], + connectionType: "ipc" | "stdio", // Communication type + relativePath: true | false, // Whether to use relative paths + serverFileNameReplacePattern: { // Server file name regex replacements + from: /pattern/, + to: "replacement" + }, + clientFileNameReplacePattern: { // Client file name regex replacements + from: /pattern/, + to: "replacement" + } + }, + ... +]; +``` \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..22bea36 --- /dev/null +++ b/index.js @@ -0,0 +1,179 @@ +const argv = require('yargs').argv; +const fs = require('fs'); +const path = require("path"); +const {servers} = require("./languageServers"); +const {spawn} = require('child_process'); +const url = require('url'); +const WebSocket = require('ws'); +const verbose = argv.verbose || false; + +const { + IPCMessageReader, + IPCMessageWriter, + StreamMessageReader, + StreamMessageWriter +} = require('vscode-jsonrpc'); +const { + formatPath, + makeClientPath, + makeServerPath +} = require("./paths-utility"); + + + +const _port = 9999; +const wss = new WebSocket.Server({port: _port}); +console.log("Started websocket server on port: ", _port); + + + +wss.on('connection', (ws, req) => { + const pathname = url.parse(req.url).pathname; + handleLanguageConnection(ws, pathname.substring(1)); +}); + + + +function handleLanguageConnection(ws, pathname) { + const server = servers.find(server => server.endpointName === pathname); + setupLanguageServer(ws, server); +} + +function setupLanguageServer(ws, server) { + if (!server) return; + + const { + reader, + writer + } = startLanguageServer(server); + + server.writer = writer; + + reader.listen(message => { + if (message.error) { + console.error(server.nameEndsWith + ":"); + console.error(message.error); + return; + } + + if (verbose) { + console.log(`From server(${server.endpointName}): `); + console.log(message); + } + + processMessage(message, ws, server); + }); + + ws.on('message', message => { + let parsed = JSON.parse(message); + + if (verbose) { + console.log("From client: "); + console.log(parsed); + } + + handleMessage(parsed, server); + }); +} + +function startLanguageServer(languageServer) { + let env = process.env; + const serverProcess = spawn(...languageServer.args, {env, shell: true}); + + serverProcess.stderr.on('data', data => { + console.error(`${serverProcess.spawnfile} error: ${data}`); + }); + + serverProcess.on('exit', code => { + fs.readdirSync("temp").forEach(file => { + fs.unlinkSync("temp" + path.sep + file); + }); + + console.log(`${serverProcess.spawnfile} exited with code ${code}`); + }); + + serverProcess.on('error', err => { + console.error(`Failed to start ${serverProcess.spawnfile}:`, err); + }); + + let reader; + let writer; + + switch (languageServer.connectionType) { + case "ipc": + reader = new IPCMessageReader(serverProcess); + writer = new IPCMessageWriter(serverProcess); + + break; + case "stdio": + if (serverProcess.stdin !== null && serverProcess.stdout !== null) { + reader = new StreamMessageReader(serverProcess.stdout); + writer = new StreamMessageWriter(serverProcess.stdin); + } else { + throw 'The language server process does not have a valid stdin or stdout'; + } + + break; + default: + throw 'Unknown connection type...'; + } + + return { + reader, + writer + }; +} + +function processMessage(message, ws, server) { + if (message.params) { + if (message.params.textDocument && message.params.textDocument.uri) { + message.params.textDocument.uri = makeClientPath( + message.params.textDocument.uri, + server.clientFileNameReplacePattern + ); + } else if (message.params.uri) { + message.params.uri = makeClientPath( + message.params.uri, + server.clientFileNameReplacePattern + ); + } + } + + ws.send(JSON.stringify(message)); +} + +function handleMessage(parsed, server) { + if (parsed.method) { + switch (parsed.method) { + case "initialize": + let rootUri = formatPath(__dirname + path.sep + "temp"); + + if (!parsed.params || (!parsed.params.rootUri && !parsed.params.rootPath && !parsed.params.workspaceFolders)) { + if (!fs.existsSync("temp")) { + fs.mkdirSync("temp"); + } + parsed.params.rootUri = rootUri; + parsed.params.rootPath = __dirname + path.sep + "temp"; + } + + break; + } + + } + + if (parsed.params && parsed.params.textDocument && parsed.params.textDocument.uri) { + parsed.params.textDocument.uri = makeServerPath( + parsed.params.textDocument.uri, + server.serverFileNameReplacePattern + ); + + if (server && server.relativePath) { + parsed.params.textDocument.uri = parsed.params.textDocument.uri.replace(__dirname + path.sep, ""); + } + } + + const writer = server?.writer; + if (writer) { + writer.write(parsed); + } +} diff --git a/languageServers.js b/languageServers.js new file mode 100644 index 0000000..1221aeb --- /dev/null +++ b/languageServers.js @@ -0,0 +1,48 @@ +exports.servers = [ + { + endpointName: "python", + args: ["pylsp"], + nameEndsWith: ".python", + connectionType: "stdio", + relativePath: false + }, { + endpointName: "go", + args: [ + 'gopls', ['-mode=stdio', '-remote=auto'] + ], + nameEndsWith: ".golang", + connectionType: "stdio", + relativePath: false, + serverFileNameReplacePattern: { + from: /.golang$/, + to: ".go" + }, + clientFileNameReplacePattern: { + from: /.go$/, + to: ".golang" + }, + }, { + endpointName: "c", + args: [ + 'clangd', ['--log=error'] + ], + nameEndsWith: ".c", + connectionType: "stdio" + }, { + endpointName: "r", + args: [ + 'r', ['--slave', '-e', 'languageserver::run()'] + ], + nameEndsWith: ".r", + connectionType: "stdio", + relativePath: false + }, { + endpointName: "typescript", + args: [ + 'typescript-language-server', ['--stdio'] + ], + nameEndsWith: ".ts", + connectionType: "stdio", + relativePath: false + }, +]; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..8819cb9 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "Newton LSP Server Bridge", + "version": "0.0.1", + "scripts": { + "start": "node index.js", + "start-verbose": "node index.js --verbose" + }, + "dependencies": { + "typescript": "^5.7.2", + "yargs": "^17.7.2" + }, + "devDependencies": { + "vscode-jsonrpc": "^8.1.0", + "vscode-uri": "^3.0.8", + "ws": "^8.13.0" + } +} \ No newline at end of file diff --git a/paths-utility.js b/paths-utility.js new file mode 100644 index 0000000..279b20e --- /dev/null +++ b/paths-utility.js @@ -0,0 +1,46 @@ +const path = require("path"); +const {URI} = require("vscode-uri"); + + + +function makeServerPath(fileName, replacement) { + if (fileName.startsWith("file:")) { + return fileName; + } + + const serverPath = formatPath(__dirname + path.sep + "temp" + path.sep + fileName); + if (replacement) { + return serverPath.replace( + replacement.from, + replacement.to + ); + } + + return serverPath; +} + +function makeClientPath(filePath, replacement) { + if (/^(file|https?):/.test(filePath)) { + return filePath; + } + + const clientPath = filePath.split(/[/\\]/).pop(); + if (replacement) { + return clientPath.replace( + replacement.from, + replacement.to + ); + } + + return clientPath; +} + +function formatPath(filePath) { + return URI.file(filePath).toString(); +} + + + +exports.formatPath = formatPath; +exports.makeClientPath = makeClientPath; +exports.makeServerPath = makeServerPath; \ No newline at end of file