feat: connect NWScript language server to Monaco via WebSocket
Add the forked nwscript-ee-language-server as a git submodule and wire it up to the editor through a WebSocket-based LSP bridge: - Backend: lsp.service.ts spawns the language server in --stdio mode and bridges JSON-RPC messages between WebSocket and stdin/stdout - Backend: /ws/lsp upgrade handler in index.ts - Frontend: LspClient class using vscode-ws-jsonrpc for JSON-RPC over WebSocket, with Monaco providers for completions, hover, and diagnostics - Frontend: useLspClient/useLspDocument hooks integrated into MonacoEditor component
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "lsp/nwscript-language-server"]
|
||||||
|
path = lsp/nwscript-language-server
|
||||||
|
url = https://github.com/layonara/nwscript-ee-language-server.git
|
||||||
Submodule
+1
Submodule lsp/nwscript-language-server added at 581f129df3
Generated
+50
-1
@@ -4674,6 +4674,53 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vscode-jsonrpc": {
|
||||||
|
"version": "8.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||||
|
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vscode-languageserver-protocol": {
|
||||||
|
"version": "3.17.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
|
||||||
|
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"vscode-jsonrpc": "8.2.0",
|
||||||
|
"vscode-languageserver-types": "3.17.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vscode-languageserver-types": {
|
||||||
|
"version": "3.17.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
|
||||||
|
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/vscode-ws-jsonrpc": {
|
||||||
|
"version": "3.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vscode-ws-jsonrpc/-/vscode-ws-jsonrpc-3.5.0.tgz",
|
||||||
|
"integrity": "sha512-13ZDy7Od4AfEPK2HIfY3DtyRi4FVsvFql1yobVJrpIoHOKGGJpIjVvIJpMxkrHzCZzWlYlg+WEu2hrYkCTvM0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"vscode-jsonrpc": "~8.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.10.0",
|
||||||
|
"npm": ">=10.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vscode-ws-jsonrpc/node_modules/vscode-jsonrpc": {
|
||||||
|
"version": "8.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz",
|
||||||
|
"integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/why-is-node-running": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
@@ -4807,7 +4854,9 @@
|
|||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0",
|
||||||
|
"vscode-languageserver-protocol": "^3.17.5",
|
||||||
|
"vscode-ws-jsonrpc": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import dockerRouter from "./routes/docker.js";
|
|||||||
import editorRouter from "./routes/editor.js";
|
import editorRouter from "./routes/editor.js";
|
||||||
import terminalRouter from "./routes/terminal.js";
|
import terminalRouter from "./routes/terminal.js";
|
||||||
import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js";
|
import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js";
|
||||||
|
import { attachLspWebSocket } from "./services/lsp.service.js";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -41,6 +42,15 @@ app.get("*path", (_req, res) => {
|
|||||||
|
|
||||||
server.on("upgrade", (request, socket, head) => {
|
server.on("upgrade", (request, socket, head) => {
|
||||||
const url = new URL(request.url || "", `http://${request.headers.host}`);
|
const url = new URL(request.url || "", `http://${request.headers.host}`);
|
||||||
|
|
||||||
|
if (url.pathname === "/ws/lsp") {
|
||||||
|
const lspWss = new WebSocketServer({ noServer: true });
|
||||||
|
lspWss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
attachLspWebSocket(ws);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const termMatch = url.pathname.match(/^\/ws\/terminal\/(.+)$/);
|
const termMatch = url.pathname.match(/^\/ws\/terminal\/(.+)$/);
|
||||||
if (termMatch) {
|
if (termMatch) {
|
||||||
const sessionId = termMatch[1];
|
const sessionId = termMatch[1];
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { spawn, ChildProcess } from "child_process";
|
||||||
|
import { WebSocket } from "ws";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
let lspProcess: ChildProcess | null = null;
|
||||||
|
let lspClient: WebSocket | null = null;
|
||||||
|
|
||||||
|
function getLspServerPath(): string {
|
||||||
|
return path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../../../lsp/nwscript-language-server/server/out/server.js",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startLspServer(): ChildProcess {
|
||||||
|
if (lspProcess && !lspProcess.killed) {
|
||||||
|
return lspProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverPath = getLspServerPath();
|
||||||
|
lspProcess = spawn("node", [serverPath, "--stdio"], {
|
||||||
|
env: { ...process.env },
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
lspProcess.stderr?.on("data", (data: Buffer) => {
|
||||||
|
console.error(`[LSP stderr] ${data.toString()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
lspProcess.on("exit", (code) => {
|
||||||
|
console.log(`[LSP] Language server exited with code ${code}`);
|
||||||
|
lspProcess = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return lspProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachLspWebSocket(ws: WebSocket): void {
|
||||||
|
const proc = startLspServer();
|
||||||
|
lspClient = ws;
|
||||||
|
|
||||||
|
ws.on("message", (data) => {
|
||||||
|
const message = data.toString();
|
||||||
|
const header = `Content-Length: ${Buffer.byteLength(message)}\r\n\r\n`;
|
||||||
|
proc.stdin?.write(header + message);
|
||||||
|
});
|
||||||
|
|
||||||
|
let buffer = "";
|
||||||
|
proc.stdout?.on("data", (chunk: Buffer) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const headerEnd = buffer.indexOf("\r\n\r\n");
|
||||||
|
if (headerEnd === -1) break;
|
||||||
|
|
||||||
|
const header = buffer.substring(0, headerEnd);
|
||||||
|
const contentLengthMatch = header.match(/Content-Length:\s*(\d+)/i);
|
||||||
|
if (!contentLengthMatch) {
|
||||||
|
buffer = buffer.substring(headerEnd + 4);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = parseInt(contentLengthMatch[1], 10);
|
||||||
|
const bodyStart = headerEnd + 4;
|
||||||
|
if (buffer.length < bodyStart + contentLength) break;
|
||||||
|
|
||||||
|
const body = buffer.substring(bodyStart, bodyStart + contentLength);
|
||||||
|
buffer = buffer.substring(bodyStart + contentLength);
|
||||||
|
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
lspClient = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopLspServer(): void {
|
||||||
|
if (lspProcess) {
|
||||||
|
lspProcess.kill();
|
||||||
|
lspProcess = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,9 @@
|
|||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0",
|
||||||
|
"vscode-languageserver-protocol": "^3.17.5",
|
||||||
|
"vscode-ws-jsonrpc": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRef, useCallback } from "react";
|
import { useRef, useCallback, useState } from "react";
|
||||||
import { Editor as ReactMonacoEditor, type OnMount } from "@monaco-editor/react";
|
import { Editor as ReactMonacoEditor, type OnMount } from "@monaco-editor/react";
|
||||||
import type { editor } from "monaco-editor";
|
import type { editor } from "monaco-editor";
|
||||||
|
import { useLspClient, useLspDocument } from "../../hooks/useLspClient.js";
|
||||||
|
|
||||||
interface MonacoEditorProps {
|
interface MonacoEditorProps {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
@@ -229,10 +230,16 @@ export function MonacoEditor({
|
|||||||
onChange,
|
onChange,
|
||||||
}: MonacoEditorProps) {
|
}: MonacoEditorProps) {
|
||||||
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
||||||
|
const [monacoRef, setMonacoRef] = useState<typeof import("monaco-editor") | null>(null);
|
||||||
|
|
||||||
|
const resolvedLang = language ?? languageFromPath(filePath);
|
||||||
|
useLspClient(monacoRef);
|
||||||
|
useLspDocument(editorRef.current, filePath, resolvedLang);
|
||||||
|
|
||||||
const handleMount: OnMount = useCallback(
|
const handleMount: OnMount = useCallback(
|
||||||
(editorInstance, monaco) => {
|
(editorInstance, monaco) => {
|
||||||
editorRef.current = editorInstance;
|
editorRef.current = editorInstance;
|
||||||
|
setMonacoRef(monaco as unknown as typeof import("monaco-editor"));
|
||||||
registerNWScript(monaco);
|
registerNWScript(monaco);
|
||||||
defineForgeTheme(monaco);
|
defineForgeTheme(monaco);
|
||||||
monaco.editor.setTheme("forge-dark");
|
monaco.editor.setTheme("forge-dark");
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { getLspClient, type LspStatus } from "../lib/lspClient.js";
|
||||||
|
import type { editor } from "monaco-editor";
|
||||||
|
|
||||||
|
export function useLspClient(monaco: typeof import("monaco-editor") | null) {
|
||||||
|
const [status, setStatus] = useState<LspStatus>("disconnected");
|
||||||
|
const connectingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!monaco || connectingRef.current) return;
|
||||||
|
|
||||||
|
const client = getLspClient();
|
||||||
|
if (client.status === "ready") {
|
||||||
|
setStatus("ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectingRef.current = true;
|
||||||
|
const unsub = client.onStatusChange(setStatus);
|
||||||
|
|
||||||
|
client.connect(monaco).catch((err) => {
|
||||||
|
console.error("[LSP] Connection failed:", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsub();
|
||||||
|
};
|
||||||
|
}, [monaco]);
|
||||||
|
|
||||||
|
return { lspClient: getLspClient(), status };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLspDocument(
|
||||||
|
editorInstance: editor.IStandaloneCodeEditor | null,
|
||||||
|
filePath: string,
|
||||||
|
language: string,
|
||||||
|
) {
|
||||||
|
const prevPathRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editorInstance) return;
|
||||||
|
|
||||||
|
const client = getLspClient();
|
||||||
|
if (client.status !== "ready") return;
|
||||||
|
|
||||||
|
const model = editorInstance.getModel();
|
||||||
|
if (!model) return;
|
||||||
|
|
||||||
|
if (prevPathRef.current && prevPathRef.current !== filePath) {
|
||||||
|
const prevUri = model.uri;
|
||||||
|
client.notifyDidClose(prevUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
client.notifyDidOpen(model.uri, language, model.getValue());
|
||||||
|
prevPathRef.current = filePath;
|
||||||
|
|
||||||
|
const changeDisposable = model.onDidChangeContent(() => {
|
||||||
|
client.notifyDidChange(model.uri, model.getValue());
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
changeDisposable.dispose();
|
||||||
|
client.notifyDidClose(model.uri);
|
||||||
|
};
|
||||||
|
}, [editorInstance, filePath, language]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import { toSocket } from "vscode-ws-jsonrpc";
|
||||||
|
import { createWebSocketConnection } from "vscode-ws-jsonrpc";
|
||||||
|
import type * as lsp from "vscode-languageserver-protocol";
|
||||||
|
import type { editor, languages, IDisposable, Uri, MarkerSeverity } from "monaco-editor";
|
||||||
|
|
||||||
|
const LSP_TRACE = false;
|
||||||
|
|
||||||
|
function trace(...args: unknown[]) {
|
||||||
|
if (LSP_TRACE) console.debug("[LSP]", ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LspStatus = "disconnected" | "connecting" | "initializing" | "ready" | "error";
|
||||||
|
|
||||||
|
export class LspClient {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private connection: ReturnType<typeof createWebSocketConnection> | null = null;
|
||||||
|
private disposables: IDisposable[] = [];
|
||||||
|
private documentVersions = new Map<string, number>();
|
||||||
|
private statusListeners = new Set<(status: LspStatus) => void>();
|
||||||
|
private _status: LspStatus = "disconnected";
|
||||||
|
private monacoInstance: typeof import("monaco-editor") | null = null;
|
||||||
|
|
||||||
|
get status() {
|
||||||
|
return this._status;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStatus(s: LspStatus) {
|
||||||
|
this._status = s;
|
||||||
|
for (const cb of this.statusListeners) cb(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatusChange(cb: (status: LspStatus) => void): () => void {
|
||||||
|
this.statusListeners.add(cb);
|
||||||
|
return () => this.statusListeners.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(monaco: typeof import("monaco-editor")): Promise<void> {
|
||||||
|
this.monacoInstance = monaco;
|
||||||
|
this.setStatus("connecting");
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const url = `${protocol}//${window.location.host}/ws/lsp`;
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
this.ws = ws;
|
||||||
|
|
||||||
|
ws.onopen = async () => {
|
||||||
|
try {
|
||||||
|
const socket = toSocket(ws);
|
||||||
|
this.connection = createWebSocketConnection(socket, {
|
||||||
|
error: (msg) => console.error("[LSP]", msg),
|
||||||
|
warn: (msg) => console.warn("[LSP]", msg),
|
||||||
|
info: (msg) => trace(msg),
|
||||||
|
log: (msg) => trace(msg),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setupNotificationHandlers(monaco);
|
||||||
|
this.connection.listen();
|
||||||
|
|
||||||
|
this.setStatus("initializing");
|
||||||
|
await this.initialize();
|
||||||
|
this.registerProviders(monaco);
|
||||||
|
this.setStatus("ready");
|
||||||
|
resolve();
|
||||||
|
} catch (err) {
|
||||||
|
this.setStatus("error");
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
this.setStatus("error");
|
||||||
|
reject(new Error("WebSocket connection failed"));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
this.setStatus("disconnected");
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initialize(): Promise<void> {
|
||||||
|
if (!this.connection) throw new Error("No connection");
|
||||||
|
|
||||||
|
const initParams: lsp.InitializeParams = {
|
||||||
|
processId: null,
|
||||||
|
capabilities: {
|
||||||
|
textDocument: {
|
||||||
|
completion: {
|
||||||
|
completionItem: {
|
||||||
|
snippetSupport: false,
|
||||||
|
documentationFormat: ["plaintext", "markdown"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hover: {
|
||||||
|
contentFormat: ["plaintext", "markdown"],
|
||||||
|
},
|
||||||
|
synchronization: {
|
||||||
|
didSave: true,
|
||||||
|
},
|
||||||
|
publishDiagnostics: {
|
||||||
|
relatedInformation: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rootUri: "file:///workspace",
|
||||||
|
workspaceFolders: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
trace("→ initialize", initParams);
|
||||||
|
const result = await this.connection.sendRequest("initialize", initParams);
|
||||||
|
trace("← initialize", result);
|
||||||
|
|
||||||
|
this.connection.sendNotification("initialized", {});
|
||||||
|
trace("→ initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupNotificationHandlers(monaco: typeof import("monaco-editor")): void {
|
||||||
|
if (!this.connection) return;
|
||||||
|
|
||||||
|
this.connection.onNotification(
|
||||||
|
"textDocument/publishDiagnostics",
|
||||||
|
(params: lsp.PublishDiagnosticsParams) => {
|
||||||
|
trace("← publishDiagnostics", params);
|
||||||
|
const uri = monaco.Uri.parse(params.uri);
|
||||||
|
const model = monaco.editor.getModel(uri);
|
||||||
|
if (!model) return;
|
||||||
|
|
||||||
|
const markers: editor.IMarkerData[] = params.diagnostics.map((d) => ({
|
||||||
|
severity: this.mapSeverity(monaco, d.severity),
|
||||||
|
startLineNumber: d.range.start.line + 1,
|
||||||
|
startColumn: d.range.start.character + 1,
|
||||||
|
endLineNumber: d.range.end.line + 1,
|
||||||
|
endColumn: d.range.end.character + 1,
|
||||||
|
message: d.message,
|
||||||
|
source: d.source,
|
||||||
|
}));
|
||||||
|
|
||||||
|
monaco.editor.setModelMarkers(model, "nwscript-lsp", markers);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapSeverity(
|
||||||
|
monaco: typeof import("monaco-editor"),
|
||||||
|
severity: lsp.DiagnosticSeverity | undefined,
|
||||||
|
): MarkerSeverity {
|
||||||
|
switch (severity) {
|
||||||
|
case 1:
|
||||||
|
return monaco.MarkerSeverity.Error;
|
||||||
|
case 2:
|
||||||
|
return monaco.MarkerSeverity.Warning;
|
||||||
|
case 3:
|
||||||
|
return monaco.MarkerSeverity.Info;
|
||||||
|
case 4:
|
||||||
|
return monaco.MarkerSeverity.Hint;
|
||||||
|
default:
|
||||||
|
return monaco.MarkerSeverity.Info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerProviders(monaco: typeof import("monaco-editor")): void {
|
||||||
|
this.disposables.push(
|
||||||
|
monaco.languages.registerCompletionItemProvider("nwscript", {
|
||||||
|
triggerCharacters: ["."],
|
||||||
|
provideCompletionItems: async (model, position) => {
|
||||||
|
if (!this.connection) return { suggestions: [] };
|
||||||
|
|
||||||
|
const params: lsp.CompletionParams = {
|
||||||
|
textDocument: { uri: model.uri.toString() },
|
||||||
|
position: {
|
||||||
|
line: position.lineNumber - 1,
|
||||||
|
character: position.column - 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
trace("→ completion", params);
|
||||||
|
const result: lsp.CompletionItem[] | lsp.CompletionList | null =
|
||||||
|
await this.connection.sendRequest("textDocument/completion", params);
|
||||||
|
trace("← completion", result);
|
||||||
|
|
||||||
|
if (!result) return { suggestions: [] };
|
||||||
|
const items = Array.isArray(result) ? result : result.items;
|
||||||
|
|
||||||
|
const suggestions: languages.CompletionItem[] = items.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
kind: this.mapCompletionKind(monaco, item.kind),
|
||||||
|
insertText: item.insertText ?? item.label,
|
||||||
|
detail: item.detail,
|
||||||
|
documentation: item.documentation
|
||||||
|
? typeof item.documentation === "string"
|
||||||
|
? item.documentation
|
||||||
|
: { value: item.documentation.value }
|
||||||
|
: undefined,
|
||||||
|
range: {
|
||||||
|
startLineNumber: position.lineNumber,
|
||||||
|
startColumn: position.column,
|
||||||
|
endLineNumber: position.lineNumber,
|
||||||
|
endColumn: position.column,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { suggestions };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.disposables.push(
|
||||||
|
monaco.languages.registerHoverProvider("nwscript", {
|
||||||
|
provideHover: async (model, position) => {
|
||||||
|
if (!this.connection) return null;
|
||||||
|
|
||||||
|
const params: lsp.HoverParams = {
|
||||||
|
textDocument: { uri: model.uri.toString() },
|
||||||
|
position: {
|
||||||
|
line: position.lineNumber - 1,
|
||||||
|
character: position.column - 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
trace("→ hover", params);
|
||||||
|
const result: lsp.Hover | null = await this.connection.sendRequest(
|
||||||
|
"textDocument/hover",
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
trace("← hover", result);
|
||||||
|
|
||||||
|
if (!result?.contents) return null;
|
||||||
|
|
||||||
|
const contents = Array.isArray(result.contents)
|
||||||
|
? result.contents.map((c) =>
|
||||||
|
typeof c === "string" ? { value: c } : { value: c.value },
|
||||||
|
)
|
||||||
|
: typeof result.contents === "string"
|
||||||
|
? [{ value: result.contents }]
|
||||||
|
: "value" in result.contents
|
||||||
|
? [{ value: result.contents.value }]
|
||||||
|
: [{ value: String(result.contents) }];
|
||||||
|
|
||||||
|
return {
|
||||||
|
contents,
|
||||||
|
range: result.range
|
||||||
|
? {
|
||||||
|
startLineNumber: result.range.start.line + 1,
|
||||||
|
startColumn: result.range.start.character + 1,
|
||||||
|
endLineNumber: result.range.end.line + 1,
|
||||||
|
endColumn: result.range.end.character + 1,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapCompletionKind(
|
||||||
|
monaco: typeof import("monaco-editor"),
|
||||||
|
kind: lsp.CompletionItemKind | undefined,
|
||||||
|
): languages.CompletionItemKind {
|
||||||
|
const k = monaco.languages.CompletionItemKind;
|
||||||
|
switch (kind) {
|
||||||
|
case 1:
|
||||||
|
return k.Text;
|
||||||
|
case 2:
|
||||||
|
return k.Method;
|
||||||
|
case 3:
|
||||||
|
return k.Function;
|
||||||
|
case 4:
|
||||||
|
return k.Constructor;
|
||||||
|
case 5:
|
||||||
|
return k.Field;
|
||||||
|
case 6:
|
||||||
|
return k.Variable;
|
||||||
|
case 7:
|
||||||
|
return k.Class;
|
||||||
|
case 8:
|
||||||
|
return k.Interface;
|
||||||
|
case 9:
|
||||||
|
return k.Module;
|
||||||
|
case 10:
|
||||||
|
return k.Property;
|
||||||
|
case 13:
|
||||||
|
return k.Enum;
|
||||||
|
case 14:
|
||||||
|
return k.Keyword;
|
||||||
|
case 15:
|
||||||
|
return k.Snippet;
|
||||||
|
case 21:
|
||||||
|
return k.Constant;
|
||||||
|
case 22:
|
||||||
|
return k.Struct;
|
||||||
|
default:
|
||||||
|
return k.Text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyDidOpen(uri: Uri, languageId: string, text: string): void {
|
||||||
|
if (!this.connection || this._status !== "ready") return;
|
||||||
|
|
||||||
|
this.documentVersions.set(uri.toString(), 1);
|
||||||
|
const params: lsp.DidOpenTextDocumentParams = {
|
||||||
|
textDocument: {
|
||||||
|
uri: uri.toString(),
|
||||||
|
languageId,
|
||||||
|
version: 1,
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
trace("→ didOpen", params.textDocument.uri);
|
||||||
|
this.connection.sendNotification("textDocument/didOpen", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyDidChange(uri: Uri, text: string): void {
|
||||||
|
if (!this.connection || this._status !== "ready") return;
|
||||||
|
|
||||||
|
const version = (this.documentVersions.get(uri.toString()) ?? 0) + 1;
|
||||||
|
this.documentVersions.set(uri.toString(), version);
|
||||||
|
const params: lsp.DidChangeTextDocumentParams = {
|
||||||
|
textDocument: { uri: uri.toString(), version },
|
||||||
|
contentChanges: [{ text }],
|
||||||
|
};
|
||||||
|
trace("→ didChange", params.textDocument.uri);
|
||||||
|
this.connection.sendNotification("textDocument/didChange", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyDidClose(uri: Uri): void {
|
||||||
|
if (!this.connection || this._status !== "ready") return;
|
||||||
|
|
||||||
|
this.documentVersions.delete(uri.toString());
|
||||||
|
const params: lsp.DidCloseTextDocumentParams = {
|
||||||
|
textDocument: { uri: uri.toString() },
|
||||||
|
};
|
||||||
|
trace("→ didClose", params.textDocument.uri);
|
||||||
|
this.connection.sendNotification("textDocument/didClose", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const d of this.disposables) d.dispose();
|
||||||
|
this.disposables = [];
|
||||||
|
this.connection?.dispose();
|
||||||
|
this.connection = null;
|
||||||
|
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
|
||||||
|
this.ws.close();
|
||||||
|
}
|
||||||
|
this.ws = null;
|
||||||
|
this.documentVersions.clear();
|
||||||
|
this.setStatus("disconnected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let globalClient: LspClient | null = null;
|
||||||
|
|
||||||
|
export function getLspClient(): LspClient {
|
||||||
|
if (!globalClient) {
|
||||||
|
globalClient = new LspClient();
|
||||||
|
}
|
||||||
|
return globalClient;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user