diff --git a/src/cm/lsp/clientManager.ts b/src/cm/lsp/clientManager.ts index a26455e30..3d37b0281 100644 --- a/src/cm/lsp/clientManager.ts +++ b/src/cm/lsp/clientManager.ts @@ -26,7 +26,7 @@ import type { BuiltinExtensionsConfig, ClientManagerOptions, ClientState, - EnsureServerResult, + DocumentUriContext, FileMetadata, FormattingOptions, LspServerDefinition, @@ -242,30 +242,23 @@ export class LspClientManager { const servers = serverRegistry.getServersForLanguage(effectiveLang); if (!servers.length) return []; - // Normalize the document URI for LSP (convert content:// to file://) - let normalizedUri = normalizeDocumentUri(originalUri); - if (!normalizedUri) { - // Fall back to cache file path for unrecognized URIs - // This allows LSP to work with any file system provider using the local cache - const cacheFile = file?.cacheFile; - if (cacheFile && typeof cacheFile === "string") { - normalizedUri = buildFileUri(cacheFile.replace(/^file:\/\//, "")); - if (normalizedUri) { - console.info( - `LSP using cache path for unrecognized URI: ${originalUri} -> ${normalizedUri}`, - ); - } - } - if (!normalizedUri) { - console.warn(`Cannot normalize document URI for LSP: ${originalUri}`); - return []; - } - } - const lspExtensions: Extension[] = []; const diagnosticsUiExtension = this.options.diagnosticsUiExtension; for (const server of servers) { + const normalizedUri = await this.#resolveDocumentUri(server, { + uri: originalUri, + file, + view, + languageId: effectiveLang, + rootUri, + }); + if (!normalizedUri) { + console.warn( + `Cannot resolve document URI for LSP server ${server.id}: ${originalUri}`, + ); + continue; + } let targetLanguageId = effectiveLang; if (server.resolveLanguageId) { try { @@ -296,7 +289,9 @@ export class LspClientManager { normalizedUri, targetLanguageId, ); - clientState.attach(normalizedUri, view as EditorView); + const aliases = + originalUri && originalUri !== normalizedUri ? [originalUri] : []; + clientState.attach(normalizedUri, view as EditorView, aliases); lspExtensions.push(plugin); } catch (error) { const lspError = error as LSPError; @@ -328,26 +323,25 @@ export class LspClientManager { const effectiveLang = safeString(languageId ?? languageName).toLowerCase(); if (!effectiveLang || !view) return false; - let normalizedUri = normalizeDocumentUri(originalUri); - if (!normalizedUri) { - const cacheFile = file?.cacheFile; - if (cacheFile && typeof cacheFile === "string") { - normalizedUri = buildFileUri(cacheFile.replace(/^file:\/\//, "")); - } - if (!normalizedUri) { - console.warn( - `Cannot normalize document URI for formatting: ${originalUri}`, - ); - return false; - } - } - const servers = serverRegistry.getServersForLanguage(effectiveLang); if (!servers.length) return false; for (const server of servers) { if (!supportsBuiltinFormatting(server)) continue; try { + const normalizedUri = await this.#resolveDocumentUri(server, { + uri: originalUri, + file, + view, + languageId: effectiveLang, + rootUri: metadata.rootUri, + }); + if (!normalizedUri) { + console.warn( + `Cannot resolve document URI for formatting with ${server.id}: ${originalUri}`, + ); + continue; + } const context: RootUriContext = { uri: normalizedUri, languageId: effectiveLang, @@ -834,28 +828,44 @@ export class LspClientManager { originalRootUri, } = params; const fileRefs = new Map>(); + const uriAliases = new Map(); const effectiveRoot = normalizedRootUri ?? originalRootUri ?? null; - const attach = (uri: string, view: EditorView): void => { + const attach = ( + uri: string, + view: EditorView, + aliases: string[] = [], + ): void => { const existing = fileRefs.get(uri) ?? new Set(); existing.add(view); fileRefs.set(uri, existing); + uriAliases.set(uri, uri); + for (const alias of aliases) { + if (!alias || alias === uri) continue; + uriAliases.set(alias, uri); + } const suffix = effectiveRoot ? ` (root ${effectiveRoot})` : ""; logLspInfo(`[LSP:${server.id}] attached to ${uri}${suffix}`); }; const detach = (uri: string, view?: EditorView): void => { - const existing = fileRefs.get(uri); + const actualUri = uriAliases.get(uri) ?? uri; + const existing = fileRefs.get(actualUri); if (!existing) return; if (view) existing.delete(view); if (!view || !existing.size) { - fileRefs.delete(uri); + fileRefs.delete(actualUri); + for (const [alias, target] of uriAliases.entries()) { + if (target === actualUri) { + uriAliases.delete(alias); + } + } try { // Only pass uri to closeFile - view is not needed for closing // and passing it may cause issues if the view is already disposed - (client.workspace as AcodeWorkspace)?.closeFile?.(uri); + (client.workspace as AcodeWorkspace)?.closeFile?.(actualUri); } catch (error) { - console.warn(`Failed to close LSP file ${uri}`, error); + console.warn(`Failed to close LSP file ${actualUri}`, error); } } @@ -897,8 +907,6 @@ export class LspClientManager { server: LspServerDefinition, context: RootUriContext, ): Promise { - if (context?.rootUri) return context.rootUri; - if (typeof server.rootUri === "function") { try { const value = await server.rootUri(context?.uri ?? "", context); @@ -908,6 +916,8 @@ export class LspClientManager { } } + if (context?.rootUri) return safeString(context.rootUri); + if (typeof this.options.resolveRoot === "function") { try { const value = await this.options.resolveRoot(context); @@ -919,6 +929,45 @@ export class LspClientManager { return null; } + + async #resolveDocumentUri( + server: LspServerDefinition, + context: RootUriContext, + ): Promise { + const originalUri = context?.uri; + if (!originalUri) return null; + + let normalizedUri = normalizeDocumentUri(originalUri); + if (!normalizedUri) { + // Fall back to cache file path for providers that do not expose a file:// URI. + const cacheFile = context.file?.cacheFile; + if (cacheFile && typeof cacheFile === "string") { + normalizedUri = buildFileUri(cacheFile.replace(/^file:\/\//, "")); + if (normalizedUri) { + console.info( + `LSP using cache path for unrecognized URI: ${originalUri} -> ${normalizedUri}`, + ); + } + } + } + + if (typeof server.documentUri === "function") { + try { + const value = await server.documentUri(originalUri, { + ...context, + normalizedUri, + } as DocumentUriContext); + if (value) return safeString(value); + } catch (error) { + console.warn( + `Server document URI resolver failed for ${server.id}`, + error, + ); + } + } + + return normalizedUri; + } } interface Change { diff --git a/src/cm/lsp/index.ts b/src/cm/lsp/index.ts index a87baff2c..38cd2cc38 100644 --- a/src/cm/lsp/index.ts +++ b/src/cm/lsp/index.ts @@ -78,6 +78,7 @@ export type { ClientManagerOptions, ClientState, DiagnosticRelatedInformation, + DocumentUriContext, FileMetadata, FormattingOptions, LSPClientWithWorkspace, diff --git a/src/cm/lsp/providerUtils.ts b/src/cm/lsp/providerUtils.ts index 511b33ddc..93e8a3094 100644 --- a/src/cm/lsp/providerUtils.ts +++ b/src/cm/lsp/providerUtils.ts @@ -27,6 +27,7 @@ export interface ManagedServerOptions { clientConfig?: LspServerManifest["clientConfig"]; resolveLanguageId?: LspServerManifest["resolveLanguageId"]; rootUri?: LspServerManifest["rootUri"]; + documentUri?: LspServerManifest["documentUri"]; capabilityOverrides?: Record; } @@ -83,6 +84,7 @@ export function defineServer(options: ManagedServerOptions): LspServerManifest { clientConfig, resolveLanguageId, rootUri, + documentUri, capabilityOverrides, } = options; @@ -118,6 +120,7 @@ export function defineServer(options: ManagedServerOptions): LspServerManifest { clientConfig, resolveLanguageId, rootUri, + documentUri, capabilityOverrides, }; } diff --git a/src/cm/lsp/serverRegistry.ts b/src/cm/lsp/serverRegistry.ts index 3ebbddf7f..88bb48263 100644 --- a/src/cm/lsp/serverRegistry.ts +++ b/src/cm/lsp/serverRegistry.ts @@ -294,6 +294,10 @@ function sanitizeDefinition( capabilityOverrides: clone(definition.capabilityOverrides), rootUri: typeof definition.rootUri === "function" ? definition.rootUri : null, + documentUri: + typeof definition.documentUri === "function" + ? definition.documentUri + : null, resolveLanguageId: typeof definition.resolveLanguageId === "function" ? definition.resolveLanguageId diff --git a/src/cm/lsp/types.ts b/src/cm/lsp/types.ts index 37e79efbf..26c4a7c47 100644 --- a/src/cm/lsp/types.ts +++ b/src/cm/lsp/types.ts @@ -42,6 +42,7 @@ export interface WorkspaceFileUpdate { // ============================================================================ export type TransportKind = "websocket" | "stdio" | "external"; +type MaybePromise = T | Promise; export interface WebSocketTransportOptions { binary?: boolean; @@ -167,6 +168,10 @@ export interface LanguageResolverContext { file?: AcodeFile; } +export interface DocumentUriContext extends RootUriContext { + normalizedUri?: string | null; +} + export interface LspServerManifest { id?: string; label?: string; @@ -178,8 +183,14 @@ export interface LspServerManifest { startupTimeout?: number; capabilityOverrides?: Record; rootUri?: - | ((uri: string, context: unknown) => string | null) - | ((uri: string, context: RootUriContext) => string | null) + | ((uri: string, context: unknown) => MaybePromise) + | ((uri: string, context: RootUriContext) => MaybePromise) + | null; + documentUri?: + | (( + uri: string, + context: DocumentUriContext, + ) => MaybePromise) | null; resolveLanguageId?: | ((context: LanguageResolverContext) => string | null) @@ -225,7 +236,15 @@ export interface LspServerDefinition { clientConfig?: AcodeClientConfig; startupTimeout?: number; capabilityOverrides?: Record; - rootUri?: ((uri: string, context: RootUriContext) => string | null) | null; + rootUri?: + | ((uri: string, context: RootUriContext) => MaybePromise) + | null; + documentUri?: + | (( + uri: string, + context: DocumentUriContext, + ) => MaybePromise) + | null; resolveLanguageId?: | ((context: LanguageResolverContext) => string | null) | null; @@ -293,7 +312,7 @@ export interface ClientState { client: LSPClient; transport: TransportHandle; rootUri: string | null; - attach: (uri: string, view: EditorView) => void; + attach: (uri: string, view: EditorView, aliases?: string[]) => void; detach: (uri: string, view?: EditorView) => void; dispose: () => Promise; }