From cd0ac6e0c7b1bc241ebecaf4ab42a4324ec81a9e Mon Sep 17 00:00:00 2001 From: gat0sy Date: Fri, 7 Aug 2026 16:07:14 +0000 Subject: [PATCH] feat(lsp): fixing LspToPosition adding a helper textEditUtils LspToPosition threw range error on format error. We attempt to fix it here with by clamping so we get the correct line count between the client and server. applyTextEdit as also been extracted so both transport and client manager can import it from the helper. --- src/cm/lsp/clientManager.ts | 183 ++++++++++++++------------- src/cm/lsp/textEditUtils.ts | 72 +++++++++++ src/cm/lsp/transport.ts | 241 ++++++++++++++++++++++++++---------- 3 files changed, 346 insertions(+), 150 deletions(-) create mode 100644 src/cm/lsp/textEditUtils.ts diff --git a/src/cm/lsp/clientManager.ts b/src/cm/lsp/clientManager.ts index 59eae1966..1f5990b26 100644 --- a/src/cm/lsp/clientManager.ts +++ b/src/cm/lsp/clientManager.ts @@ -9,7 +9,7 @@ import { serverCompletion, serverDiagnostics, } from "@codemirror/lsp-client"; -import { EditorState, Extension, Facet, MapMode } from "@codemirror/state"; +import { EditorState, Extension, Facet } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import lspStatusBar from "components/lspStatusBar"; import notificationManager from "lib/notificationManager"; @@ -52,6 +52,9 @@ import type { Transport, } from "./types"; import AcodeWorkspace from "./workspace"; +import { applyTextEdits } from "./textEditUtils"; + +const LSP_IDLE_GRACE_MS = 45_000; // grace period before a client with no open files is actually disposed export const lspCompletionEnabled = Facet.define({ // File-level marker used by the autocomplete override path. If any attached @@ -183,9 +186,14 @@ function connectClient( initializationOptions?: Record, rootUri?: string | null, ): void { - const hasInitializationOptions = - !!initializationOptions && Object.keys(initializationOptions).length > 0; - if (!hasInitializationOptions && !rootUri) { + const workspaceFolders = rootUri + ? [{ uri: rootUri, name: deriveFolderName(rootUri) }] + : undefined; + + if ( + (!initializationOptions || !Object.keys(initializationOptions).length) && + !workspaceFolders + ) { client.connect(transport); return; } @@ -205,14 +213,8 @@ function connectClient( if (method === "initialize" && isPlainObject(params)) { params = { ...params, - ...(hasInitializationOptions ? { initializationOptions } : {}), - ...(rootUri - ? { - workspaceFolders: [ - { uri: rootUri, name: workspaceName(rootUri) }, - ], - } - : {}), + ...(initializationOptions ? { initializationOptions } : {}), + ...(workspaceFolders ? { workspaceFolders } : {}), } as Params; } return originalRequestInner(method, params, mapped); @@ -225,13 +227,14 @@ function connectClient( } } -function workspaceName(rootUri: string): string { - const trimmed = rootUri.replace(/\/+$/, ""); - const encodedName = trimmed.slice(trimmed.lastIndexOf("/") + 1); +function deriveFolderName(uri: string): string { try { - return decodeURIComponent(encodedName) || "workspace"; + const decoded = decodeURIComponent(uri); + const trimmed = decoded.replace(/\/+$/, ""); + const segments = trimmed.split("/").filter(Boolean); + return segments[segments.length - 1] || decoded; } catch { - return encodedName || "workspace"; + return uri; } } @@ -797,8 +800,17 @@ export class LspClientManager { }, workspace: { configuration: true, + applyEdit: true, workspaceFolders: true, }, + textDocument: { + codeAction: { + dataSupport: true, + resolveSupport: { + properties: ["edit"], + }, + }, + }, }, }; @@ -1045,14 +1057,20 @@ export class LspClientManager { client = new LSPClient(clientConfig) as ExtendedLSPClient; client.__acodeServerId = server.id; connectClient( - client, - transportHandle.transport, - initializationOptions, - scope === "workspace" && server.useWorkspaceFolders - ? null - : normalizedRootUri, - ); + client, + transportHandle.transport, + initializationOptions, + normalizedRootUri, +); await waitForInitialization(client.initializing, signal, server.id); + // Fire after "initialized" + // it reuses initializationOptions as the config payload + // For LSPs sometimes requiring config to be sent twice like pylsp) + transportHandle.transport.send(JSON.stringify({ + jsonrpc: "2.0", + method: "workspace/didChangeConfiguration", + params: { settings: server.initializationOptions ?? {} }, + })); if (!client.__acodeLoggedInfo) { // Log root URI info to console if (normalizedRootUri) { @@ -1135,23 +1153,27 @@ export class LspClientManager { const uriAliases = new Map(); const effectiveRoot = normalizedRootUri ?? originalRootUri ?? null; let disposed = false; - + let idleTimer: ReturnType | undefined; 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}`); - }; + uri: string, + view: EditorView, + aliases: string[] = [], +): void => { + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = undefined; + } + 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 clearClientDiagnostics = (view: EditorView): void => { try { @@ -1164,6 +1186,10 @@ export class LspClientManager { const dispose = async (): Promise => { if (disposed) return; disposed = true; + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = undefined; + } disposePullDiagnostics(client); this.#clients.delete(key); for (const views of fileRefs.values()) { @@ -1206,13 +1232,18 @@ export class LspClientManager { } if (!fileRefs.size) { - this.options.onClientIdle?.({ - server, - client, - rootUri: effectiveRoot, - dispose, - }); - } + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + idleTimer = undefined; + if (fileRefs.size) return; // a file reattached during the grace window + this.options.onClientIdle?.({ + server, + client, + rootUri: effectiveRoot, + dispose, + }); + }, LSP_IDLE_GRACE_MS); +} }; return { @@ -1404,45 +1435,6 @@ interface Change { insert: string; } -function applyTextEdits( - plugin: LSPPlugin, - view: EditorView, - edits: TextEdit[], -): boolean { - const changes: Change[] = []; - for (const edit of edits) { - if (!edit?.range) continue; - let fromBase: number; - let toBase: number; - try { - fromBase = plugin.fromPosition(edit.range.start, plugin.syncedDoc); - toBase = plugin.fromPosition(edit.range.end, plugin.syncedDoc); - } catch (_) { - continue; - } - const fromResult = plugin.unsyncedChanges.mapPos( - fromBase, - 1, - MapMode.TrackDel, - ); - const toResult = plugin.unsyncedChanges.mapPos( - toBase, - -1, - MapMode.TrackDel, - ); - if (fromResult == null || toResult == null) continue; - const insert = - typeof edit.newText === "string" - ? edit.newText.replace(/\r\n/g, "\n") - : ""; - changes.push({ from: fromResult, to: toResult, insert }); - } - if (!changes.length) return false; - changes.sort((a, b) => a.from - b.from || a.to - b.to); - view.dispatch({ changes }); - return true; -} - function buildFormattingOptions( view: EditorView, overrides: FormattingOptions = {}, @@ -1482,6 +1474,7 @@ function resolveIndentWidth(unit: string): number { return width || 4; } + const defaultManager = new LspClientManager(); export default defaultManager; @@ -1500,13 +1493,13 @@ function normalizeRootUriForServer( if (scheme === "file") { return { normalizedRootUri: rootUri, originalRootUri: rootUri }; } - // Try to convert content:// URIs to file:// URIs if (scheme === "content") { const fileUri = contentUriToFileUri(rootUri); if (fileUri) { return { normalizedRootUri: fileUri, originalRootUri: rootUri }; } + // Can't convert to file:// - server won't work properly return { normalizedRootUri: null, originalRootUri: rootUri }; } @@ -1525,6 +1518,12 @@ function normalizeDocumentUri(uri: string | null | undefined): string | null { if (scheme === "file" || scheme === "untitled") { return uri; } + + // sftp documents: strip to the bare remote path + if (scheme === "sftp") { + return sftpUriToFileUri(uri); + } + // Convert content:// URIs to file:// URIs if (scheme === "content") { @@ -1611,6 +1610,16 @@ function contentUriToFileUri(uri: string): string | null { } } +function sftpUriToFileUri(uri: string): string | null { + // reached via this SFTP connection, so the server needs only the bare + // remote path — no scheme, host, port, or credentials. + const match = /^sftp:\/\/[^/]*(\/.*)$/.exec(uri); + if (!match) return null; + const path = match[1].split("?")[0]; + if (!path) return null; + return buildFileUri(path); +} + function buildFileUri(pathname: string): string | null { if (!pathname) return null; const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`; diff --git a/src/cm/lsp/textEditUtils.ts b/src/cm/lsp/textEditUtils.ts new file mode 100644 index 000000000..76576fba9 --- /dev/null +++ b/src/cm/lsp/textEditUtils.ts @@ -0,0 +1,72 @@ +import type { LSPPlugin } from "@codemirror/lsp-client"; +import type { EditorView } from "@codemirror/view"; +import { MapMode } from "@codemirror/state"; +import type { Text } from "@codemirror/state"; +import type { TextEdit } from "vscode-languageserver-types"; + +interface Change { + from: number; + to: number; + insert: string; +} + +/** + * Convert an LSP Position to a CodeMirror document offset, clamping to + * document bounds. Handles the LSP convention where line == doc.lines + * means "end of document" (e.g. full-document formatting replacements). + */ +export function lspPositionToOffset( + doc: Text, + pos: { line: number; character: number }, +): number { + if (pos.line < 0) return 0; + if (pos.line >= doc.lines) return doc.length; + const line = doc.line(pos.line + 1); + return line.from + Math.min(pos.character, line.length); +} + +/** + * Apply a list of LSP TextEdits to an EditorView backed by the given + * LSPPlugin. Shared by clientManager.ts (edits the client pulls, e.g. + * textDocument/formatting) and transport.ts (edits the server pushes, + * e.g. workspace/applyEdit). + */ +export function applyTextEdits( + plugin: LSPPlugin, + view: EditorView, + edits: TextEdit[], +): boolean { + const changes: Change[] = []; + for (const edit of edits) { + if (!edit?.range) continue; + let fromBase: number; + let toBase: number; + try { + fromBase = lspPositionToOffset(plugin.syncedDoc, edit.range.start); + toBase = lspPositionToOffset(plugin.syncedDoc, edit.range.end); + } catch (err) { + console.error("[applyTextEdits] position conversion failed:", err, edit); + continue; + } + const fromResult = plugin.unsyncedChanges.mapPos( + fromBase, + 1, + MapMode.TrackDel, + ); + const toResult = plugin.unsyncedChanges.mapPos( + toBase, + -1, + MapMode.TrackDel, + ); + if (fromResult == null || toResult == null) continue; + const insert = + typeof edit.newText === "string" + ? edit.newText.replace(/\r\n/g, "\n") + : ""; + changes.push({ from: fromResult, to: toResult, insert }); + } + if (!changes.length) return false; + changes.sort((a, b) => a.from - b.from || a.to - b.to); + view.dispatch({ changes }); + return true; +} \ No newline at end of file diff --git a/src/cm/lsp/transport.ts b/src/cm/lsp/transport.ts index 1095687e6..6a59e01eb 100644 --- a/src/cm/lsp/transport.ts +++ b/src/cm/lsp/transport.ts @@ -11,6 +11,10 @@ import type { TransportHandle, WebSocketTransportOptions, } from "./types"; +import { LSPPlugin } from "@codemirror/lsp-client"; +import type { TextEdit } from "vscode-languageserver-types"; +import { applyTextEdits } from "./textEditUtils"; +import type AcodeWorkspace from "./workspace"; const DEFAULT_TIMEOUT = 5000; const RECONNECT_BASE_DELAY = 500; @@ -157,76 +161,187 @@ function createWebSocketTransport( dispatchToListeners(data); } - function dispatchToListeners(data: string): void { - // Debugging aid while stabilising websocket transport - if (context?.debugWebSocket) { - console.debug(`[LSP:${server.id}] <=`, data); +interface WorkspaceEditParam { + changes?: Record; + documentChanges?: Array<{ textDocument: { uri: string }; edits: TextEdit[] }>; +} + +async function applyWorkspaceEditToContext( + edit: WorkspaceEditParam | undefined, + ctx: TransportContext, +): Promise<{ applied: boolean; failureReason?: string }> { + if (!edit) return { applied: false, failureReason: "No edit provided" }; + + const changesByUri: Record = + edit.changes ?? + Object.fromEntries( + (edit.documentChanges ?? []) + .filter((c): c is { textDocument: { uri: string }; edits: TextEdit[] } => "edits" in c) + .map((c) => [c.textDocument.uri, c.edits]), + ); + + const uris = Object.keys(changesByUri); + if (!uris.length) { + return { applied: false, failureReason: "Edit contains no changes" }; + } + + const workspace = ctx.view + ? (LSPPlugin.get(ctx.view)?.client.workspace as AcodeWorkspace | undefined) + : undefined; + + if (!workspace) { + return { applied: false, failureReason: "No workspace available to apply edit" }; + } + + let appliedCount = 0; + const failures: string[] = []; + + for (const uri of uris) { + const edits = changesByUri[uri]; + if (!edits.length) continue; + + let view = workspace.getFile(uri)?.getView(); + if (!view) { + try { + view = await workspace.displayFile(uri); + } catch (error) { + failures.push(uri); + continue; + } + } + if (!view) { + failures.push(uri); + continue; } - try { - const msg = JSON.parse(data); - if (msg && typeof msg.id !== "undefined") { - let handled = true; - let result: unknown = null; - switch (msg.method) { - case "window/workDoneProgress/create": - case "workspace/diagnostic/refresh": - case "client/registerCapability": - case "client/unregisterCapability": - break; - case "workspace/configuration": - result = Array.isArray(msg.params?.items) - ? msg.params.items.map( - (item: { section?: unknown }) => - resolveWorkspaceConfiguration(item?.section), - ) - : []; - break; - case "workspace/workspaceFolders": { - const rootUri = context.rootUri; - result = rootUri - ? [ - { - uri: rootUri, - name: - rootUri.replace(/\/$/, "").split("/").pop() || - rootUri, - }, - ] - : null; - break; - } - default: - handled = false; - } - if (!handled) { - notifyListeners(data); - return; - } - const response = JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result, - }); - if (context?.debugWebSocket) { - console.debug(`[LSP:${server.id}] => (auto-response)`, response); - } - sendMessage(response); - if (msg.method === "workspace/diagnostic/refresh") { - notifyListeners( - JSON.stringify({ + const plugin = LSPPlugin.get(view); + if (!plugin) { + failures.push(uri); + continue; + } + + const applied = applyTextEdits(plugin, view, edits); + if (applied) appliedCount++; + else failures.push(uri); + } + + if (appliedCount === 0) { + return { + applied: false, + failureReason: `Could not apply edit to: ${failures.join(", ")}`, + }; + } + if (failures.length) { + return { + applied: false, + failureReason: `Applied to ${appliedCount} file(s); failed: ${failures.join(", ")}`, + }; + } + return { applied: true }; +} + + function dispatchToListeners(data: string): void { + // Debugging aid while stabilising websocket transport + if (context?.debugWebSocket) { + console.debug(`[LSP:${server.id}] <=`, data); + } + + try { + const msg = JSON.parse(data); + if (msg && typeof msg.id !== "undefined") { + // workspace/applyEdit needs to await file-opening/edit-application, + // so it can't go through the synchronous switch below. Handle it + // separately and return immediately. + if (msg.method === "workspace/applyEdit") { + applyWorkspaceEditToContext(msg.params?.edit, context) + .then((result) => { + const response = JSON.stringify({ jsonrpc: "2.0", - method: msg.method, - params: msg.params ?? {}, - }), - ); + id: msg.id, + result, + }); + if (context?.debugWebSocket) { + console.debug(`[LSP:${server.id}] => (auto-response)`, response); + } + sendMessage(response); + }) + .catch((error) => { + console.error(`[LSP:${server.id}] workspace/applyEdit failed:`, error); + sendMessage( + JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result: { + applied: false, + failureReason: "Internal error applying edit", + }, + }), + ); + }); + return; + } + + let handled = true; + let result: unknown = null; + switch (msg.method) { + case "window/workDoneProgress/create": + case "workspace/diagnostic/refresh": + case "client/registerCapability": + case "client/unregisterCapability": + break; + case "workspace/configuration": + result = Array.isArray(msg.params?.items) + ? msg.params.items.map( + (item: { section?: unknown }) => + resolveWorkspaceConfiguration(item?.section), + ) + : []; + break; + case "workspace/workspaceFolders": { + const rootUri = context.rootUri; + result = rootUri + ? [ + { + uri: rootUri, + name: + rootUri.replace(/\/$/, "").split("/").pop() || + rootUri, + }, + ] + : null; + break; } + default: + handled = false; + } + if (!handled) { + notifyListeners(data); return; } - } catch (_) {} + const response = JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result, + }); + if (context?.debugWebSocket) { + console.debug(`[LSP:${server.id}] => (auto-response)`, response); + } + sendMessage(response); + if (msg.method === "workspace/diagnostic/refresh") { + notifyListeners( + JSON.stringify({ + jsonrpc: "2.0", + method: msg.method, + params: msg.params ?? {}, + }), + ); + } + return; + } + } catch (_) {} - notifyListeners(data); - } + notifyListeners(data); +} function handleClose(event: CloseEvent): void { connected = false;