From 31c6e3cbafcf6fc3df4ddd04ffc17e4ae076407e Mon Sep 17 00:00:00 2001 From: Aofei Sheng Date: Thu, 2 Jul 2026 16:39:28 +0800 Subject: [PATCH] feat: initialize LSP clients before requests Send the LSP `initialize` request before ordinary client requests and follow it with the `initialized` notification after the server accepts the handshake. This declares the editor capabilities the client consumes and gates requests until the server is ready. Thread the UI locale into the initialization params and align JSON-RPC IDs with the LSP `MessageID` shape used by request and response messages. Updates goplus/xgolsw#329 Signed-off-by: Aofei Sheng --- .../xgo-code-editor/XGoCodeEditor.ce.vue | 2 +- .../spx-code-editor/CodeEditorProvider.vue | 18 +-- .../spx-code-editor/lsp/spx-lsp-client.ts | 136 +++++++++++++++--- .../spx-code-editor/lsp/spxls/client.ts | 12 +- tools/spxls/go.mod | 2 + tools/spxls/go.sum | 4 +- tools/spxls/index.d.ts | 9 +- 7 files changed, 150 insertions(+), 33 deletions(-) diff --git a/spx-gui/src/apps/xbuilder/widgets/xgo-code-editor/XGoCodeEditor.ce.vue b/spx-gui/src/apps/xbuilder/widgets/xgo-code-editor/XGoCodeEditor.ce.vue index 81faaea98d..57b8610229 100644 --- a/spx-gui/src/apps/xbuilder/widgets/xgo-code-editor/XGoCodeEditor.ce.vue +++ b/spx-gui/src/apps/xbuilder/widgets/xgo-code-editor/XGoCodeEditor.ce.vue @@ -69,7 +69,7 @@ const { await project.load(serialized) const history = new History(project) - const lspClient = new SpxLSPClient(project) + const lspClient = new SpxLSPClient(project, { locale: i18n.lang.value }) lspClient.init() const documentBase = new DocumentBase([...Object.values(spxDefinitionsByName), ...spxKeyDefinitions]) diff --git a/spx-gui/src/components/editor/spx-code-editor/CodeEditorProvider.vue b/spx-gui/src/components/editor/spx-code-editor/CodeEditorProvider.vue index c276fcddc1..10ff3e4f5e 100644 --- a/spx-gui/src/components/editor/spx-code-editor/CodeEditorProvider.vue +++ b/spx-gui/src/components/editor/spx-code-editor/CodeEditorProvider.vue @@ -1,12 +1,8 @@ - - + + diff --git a/spx-gui/src/components/editor/spx-code-editor/lsp/spx-lsp-client.ts b/spx-gui/src/components/editor/spx-code-editor/lsp/spx-lsp-client.ts index 333e82abfe..8d3d343e09 100644 --- a/spx-gui/src/components/editor/spx-code-editor/lsp/spx-lsp-client.ts +++ b/spx-gui/src/components/editor/spx-code-editor/lsp/spx-lsp-client.ts @@ -4,6 +4,7 @@ import * as Sentry from '@sentry/vue' import { Cancelled } from '@/utils/exception' import { Disposable, getCleanupSignal, type Disposer } from '@/utils/disposable' import Emitter from '@/utils/emitter' +import type { Lang } from '@/utils/i18n' import { timeout, until, untilNotNull } from '@/utils/utils' import { extname } from '@/utils/path' import { createLSPOperationName, createLSPServerOperationName, type LSPTraceOptions } from '@/utils/tracing' @@ -23,7 +24,7 @@ import { type PropertyRenamedEvent } from '@/components/xgo-code-editor' import { XGoLanguageClient, type IConnection, ResponseError } from './spxls/client' -import type { Files as SpxlsFiles, RequestMessage, ResponseMessage, NotificationMessage } from './spxls' +import type { Files as SpxlsFiles, MessageID, RequestMessage, ResponseMessage, NotificationMessage } from './spxls' import { xgoGetInputSlots, xgoGetProperties, xgoRenameResources } from './spxls/commands' import { xgoPropertyRenamedNotification } from './spxls/notifications' import { isDocumentLinkForResourceReference, parseDocumentLinkForDefinition } from './spxls/methods' @@ -32,11 +33,23 @@ import type { WorkerHandler } from './worker' /** The LSP target name for stage (the "Game" type in spx). */ const lspStageTarget = 'Game' +const lspClientName = 'XBuilder' +const lspWorkspaceRootURI = 'file:///' +const lspWorkspaceRootName = 'workspace' + interface IConnectionWithFiles extends IConnection { sendFiles(files: SpxlsFiles): void dispose(): void } +type SpxLSPClientOptions = { + locale?: Lang +} + +function isServerNotInitializedError(error: unknown): error is ResponseError { + return error instanceof ResponseError && error.code === lsp.ErrorCodes.ServerNotInitialized +} + /** Connection between LS client and server when the server runs in a Web Worker. */ class WorkerConnection implements IConnectionWithFiles { private worker: WorkerHandler @@ -115,7 +128,10 @@ type TelemetryEventParams = TelemetryEventParamsForCall | TelemetryEventParamsFo export class SpxLSPClient extends Disposable implements ILSPClient { private emitter = new Emitter() - constructor(private project: SpxProject) { + constructor( + private project: SpxProject, + private options: SpxLSPClientOptions = {} + ) { super() this.addDisposable(this.emitter) } @@ -155,18 +171,20 @@ export class SpxLSPClient extends Disposable implements ILSPClient { } private lcRef = shallowRef(null) - private activeSpans = new Map() + private initializePromise: Promise | null = null + private activeSpans = new Map() private async prepareRequest() { - const [lc] = await Promise.all([ - untilNotNull(this.lcRef), - // Typically requests are triggered earlier than file-loading: - // * file-loading: editor-event -> model-update -> file-loading - // * request: editor-event -> request - // Here we add `timeout(0)` to ensure that file-loading triggered before request really starts. - timeout(0).then(() => until(() => !this.isFilesStale.value)) - ]) - return lc + const lc = await untilNotNull(this.lcRef) + const initializePromise = this.initializePromise ?? this.startInitialize(lc) + // Typically requests are triggered earlier than file-loading: + // * file-loading: editor-event -> model-update -> file-loading + // * request: editor-event -> request + // Here we add `timeout(0)` to ensure that file-loading triggered before request really starts. + await timeout(0) + await until(() => !this.isFilesStale.value) + await initializePromise + return { lc, initializePromise } } init() { @@ -179,6 +197,8 @@ export class SpxLSPClient extends Disposable implements ILSPClient { this.handleTelemetryEventNotification(params) }) + this.startInitialize(this.lcRef.value) + // Register handler for property renamed notifications this.lcRef.value.onNotification( xgoPropertyRenamedNotification.method, @@ -192,15 +212,85 @@ export class SpxLSPClient extends Disposable implements ILSPClient { ) } + private createInitializeParams(): lsp.InitializeParams { + return { + processId: null, + clientInfo: { + name: lspClientName + }, + locale: this.options.locale, + rootUri: lspWorkspaceRootURI, + workspaceFolders: [ + { + uri: lspWorkspaceRootURI, + name: lspWorkspaceRootName + } + ], + capabilities: { + workspace: { + workspaceFolders: true + }, + general: { + positionEncodings: [lsp.PositionEncodingKind.UTF16] + }, + textDocument: { + completion: { + completionItem: { + snippetSupport: true, + documentationFormat: [lsp.MarkupKind.Markdown] + } + }, + hover: { + contentFormat: [lsp.MarkupKind.Markdown] + }, + rename: { + prepareSupport: true + }, + signatureHelp: { + contextSupport: true + } + } + } + } + } + + private async initialize(lc: XGoLanguageClient) { + await this.sendRequest(lc, {}, lsp.InitializeRequest.method, this.createInitializeParams()) + lc.notify(lsp.InitializedNotification.method, {}) + } + + private startInitialize(lc: XGoLanguageClient) { + const initializePromise = this.initialize(lc) + this.initializePromise = initializePromise + initializePromise.catch(() => { + if (this.initializePromise === initializePromise) { + this.initializePromise = null + } + }) + return initializePromise + } + + private reinitialize(lc: XGoLanguageClient, staleInitializePromise: Promise) { + const currentInitializePromise = this.initializePromise + if (currentInitializePromise != null && currentInitializePromise !== staleInitializePromise) { + return currentInitializePromise + } + return this.startInitialize(lc) + } + dispose() { this.lcRef.value?.dispose() this.connection?.dispose() super.dispose() } - /** Do LSP request, with cancellation and tracing. */ - private async request({ signal, traceOptions }: RequestContext, method: string, params: any): Promise { - const lc = await this.prepareRequest() + private async sendRequest( + lc: XGoLanguageClient, + { signal, traceOptions }: RequestContext, + method: string, + params: any, + suppressErrorLog?: (error: unknown) => boolean + ): Promise { return tracedRequest( method, async (span: Sentry.Span) => { @@ -226,7 +316,7 @@ export class SpxLSPClient extends Disposable implements ILSPClient { if (e instanceof ResponseError && e.code === lsp.LSPErrorCodes.RequestCancelled) { throw new Cancelled(e.message) } - console.warn(`[LSP] ${method} error:`, e, ', params:', params) + if (!suppressErrorLog?.(e)) console.warn(`[LSP] ${method} error:`, e, ', params:', params) throw e }) .finally(unlisten) @@ -235,7 +325,19 @@ export class SpxLSPClient extends Disposable implements ILSPClient { ) } - private async cancelRequest(id: number) { + /** Do LSP request, with cancellation and tracing. */ + private async request(ctx: RequestContext, method: string, params: any): Promise { + const { lc, initializePromise } = await this.prepareRequest() + try { + return await this.sendRequest(lc, ctx, method, params, isServerNotInitializedError) + } catch (e) { + if (!isServerNotInitializedError(e)) throw e + await this.reinitialize(lc, initializePromise) + return this.sendRequest(lc, ctx, method, params) + } + } + + private async cancelRequest(id: MessageID) { const lc = await untilNotNull(this.lcRef) // The method `$/cancelRequest` is defined in https://github.com/microsoft/vscode-languageserver-node/blob/4c20197acf4c499345a18e79945e706345cbc50f/protocol/src/common/protocol.%24.ts#L46-L58 , // while not exported by package `vscode-languageserver-protocol`. diff --git a/spx-gui/src/components/editor/spx-code-editor/lsp/spxls/client.ts b/spx-gui/src/components/editor/spx-code-editor/lsp/spxls/client.ts index 1539656542..676f2156e1 100644 --- a/spx-gui/src/components/editor/spx-code-editor/lsp/spxls/client.ts +++ b/spx-gui/src/components/editor/spx-code-editor/lsp/spxls/client.ts @@ -1,4 +1,10 @@ -import type { NotificationMessage, RequestMessage, ResponseMessage, ResponseError as ResponseErrorObj } from './index' +import type { + MessageID, + NotificationMessage, + RequestMessage, + ResponseMessage, + ResponseError as ResponseErrorObj +} from './index' /** Connection between the client and the language server. */ export interface IConnection { @@ -8,7 +14,7 @@ export interface IConnection { export interface OngoingRequest { /** Unique ID of the request. */ - id: number + id: MessageID /** Returns a promise that resolves with the response of the request. */ response(): Promise } @@ -19,7 +25,7 @@ export interface OngoingRequest { export class XGoLanguageClient { private nextRequestId: number = 1 private pendingRequests = new Map< - number, + MessageID, { resolve: (response: any) => void reject: (error: any) => void diff --git a/tools/spxls/go.mod b/tools/spxls/go.mod index 31f44b7bd4..6dc783c136 100644 --- a/tools/spxls/go.mod +++ b/tools/spxls/go.mod @@ -26,3 +26,5 @@ require ( ) replace github.com/goplus/builder/tools/ai => ../ai + +replace github.com/goplus/xgolsw => github.com/aofei/fork.goplus.xgolsw v0.0.0-20260701122425-18fd0bb225b8 diff --git a/tools/spxls/go.sum b/tools/spxls/go.sum index edaab89346..cfed397e9c 100644 --- a/tools/spxls/go.sum +++ b/tools/spxls/go.sum @@ -1,4 +1,6 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/aofei/fork.goplus.xgolsw v0.0.0-20260701122425-18fd0bb225b8 h1:xvXKSHybkyjRga8bFKuRD4McZooDvskHSSH4rAZC+Oo= +github.com/aofei/fork.goplus.xgolsw v0.0.0-20260701122425-18fd0bb225b8/go.mod h1:ANgB42yf5ul2VDNMxp6v2yxbcwyNu1wV9IWekAgGSG4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= @@ -15,8 +17,6 @@ github.com/goplus/spx/v2 v2.0.4 h1:4hyCM5XH3DMRjFeoTxCndrqcFZTeCWHkEicyp7wYTSs= github.com/goplus/spx/v2 v2.0.4/go.mod h1:mF2kCvUNw97ku2PYHMEwbCENv24b5EwnlXOLiVMRbHM= github.com/goplus/xgo v1.7.2 h1:leAinbUiz+oUukKfdYIXyYZlxm/gh6TkEg5/dzvevQk= github.com/goplus/xgo v1.7.2/go.mod h1:QR2TaWixDRCYQfv0F7xpZbUPvuclcgg0oUFmcq5nNn0= -github.com/goplus/xgolsw v0.21.1-0.20260630012157-8eb11678c926 h1:LDYoxTq7UxoZppnHfCPQswH30MevNyoLTX8l0hrKFzo= -github.com/goplus/xgolsw v0.21.1-0.20260630012157-8eb11678c926/go.mod h1:ANgB42yf5ul2VDNMxp6v2yxbcwyNu1wV9IWekAgGSG4= github.com/petermattis/goid v0.0.0-20250721140440-ea1c0173183e h1:D0bJD+4O3G4izvrQUmzCL80zazlN7EwJ0PPDhpJWC/I= github.com/petermattis/goid v0.0.0-20250721140440-ea1c0173183e/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/tools/spxls/index.d.ts b/tools/spxls/index.d.ts index d9d5a920f9..b53efcd0b0 100644 --- a/tools/spxls/index.d.ts +++ b/tools/spxls/index.d.ts @@ -48,6 +48,11 @@ export interface Message { jsonrpc: string } +/** + * A JSON-RPC message identifier. + */ +export type MessageID = number | string + /** * A request message to describe a request between the client and the server. Every processed request must send a * response back to the sender of the request. @@ -58,7 +63,7 @@ export interface RequestMessage extends Message { /** * The request id. */ - id: number + id: MessageID /** * The method to be invoked. @@ -82,7 +87,7 @@ export interface ResponseMessage extends Message { /** * The request id. */ - id: number + id: MessageID /** * The result of a request. This member is REQUIRED on success.