diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..bad58f9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lsp/nwscript-language-server"] + path = lsp/nwscript-language-server + url = https://github.com/layonara/nwscript-ee-language-server.git diff --git a/lsp/nwscript-language-server b/lsp/nwscript-language-server new file mode 160000 index 0000000..581f129 --- /dev/null +++ b/lsp/nwscript-language-server @@ -0,0 +1 @@ +Subproject commit 581f129df3de5c9296271e3e7f7f67275f46e749 diff --git a/package-lock.json b/package-lock.json index fe7aefe..0558600 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { "version": "2.3.0", "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", "react": "^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": { "@types/react": "^19.0.0", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 7b4a647..99c2a13 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -10,6 +10,7 @@ import dockerRouter from "./routes/docker.js"; import editorRouter from "./routes/editor.js"; import terminalRouter from "./routes/terminal.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 app = express(); @@ -41,6 +42,15 @@ app.get("*path", (_req, res) => { server.on("upgrade", (request, socket, head) => { 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\/(.+)$/); if (termMatch) { const sessionId = termMatch[1]; diff --git a/packages/backend/src/services/lsp.service.ts b/packages/backend/src/services/lsp.service.ts new file mode 100644 index 0000000..2a430b2 --- /dev/null +++ b/packages/backend/src/services/lsp.service.ts @@ -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; + } +} diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 18e27b9..43e0f60 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -15,7 +15,9 @@ "monaco-editor": "^0.55.1", "react": "^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": { "@types/react": "^19.0.0", diff --git a/packages/frontend/src/components/editor/MonacoEditor.tsx b/packages/frontend/src/components/editor/MonacoEditor.tsx index 80475de..9e03914 100644 --- a/packages/frontend/src/components/editor/MonacoEditor.tsx +++ b/packages/frontend/src/components/editor/MonacoEditor.tsx @@ -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 type { editor } from "monaco-editor"; +import { useLspClient, useLspDocument } from "../../hooks/useLspClient.js"; interface MonacoEditorProps { filePath: string; @@ -229,10 +230,16 @@ export function MonacoEditor({ onChange, }: MonacoEditorProps) { const editorRef = useRef(null); + const [monacoRef, setMonacoRef] = useState(null); + + const resolvedLang = language ?? languageFromPath(filePath); + useLspClient(monacoRef); + useLspDocument(editorRef.current, filePath, resolvedLang); const handleMount: OnMount = useCallback( (editorInstance, monaco) => { editorRef.current = editorInstance; + setMonacoRef(monaco as unknown as typeof import("monaco-editor")); registerNWScript(monaco); defineForgeTheme(monaco); monaco.editor.setTheme("forge-dark"); diff --git a/packages/frontend/src/hooks/useLspClient.ts b/packages/frontend/src/hooks/useLspClient.ts new file mode 100644 index 0000000..ce736ed --- /dev/null +++ b/packages/frontend/src/hooks/useLspClient.ts @@ -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("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(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]); +} diff --git a/packages/frontend/src/lib/lspClient.ts b/packages/frontend/src/lib/lspClient.ts new file mode 100644 index 0000000..f493981 --- /dev/null +++ b/packages/frontend/src/lib/lspClient.ts @@ -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 | null = null; + private disposables: IDisposable[] = []; + private documentVersions = new Map(); + 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 { + 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((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 { + 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; +}