From 469d87899300677738515494fb2bb5cf25d52aeb Mon Sep 17 00:00:00 2001 From: IntrntSrfr Date: Wed, 5 Nov 2025 22:56:43 +0100 Subject: [PATCH] Add support for following symlinks in navigation --- package.json | 5 + src/clangd-context.ts | 337 +++++++++++++++++++++++++++++++----------- 2 files changed, 255 insertions(+), 87 deletions(-) diff --git a/package.json b/package.json index 21081f3..b36c9b8 100644 --- a/package.json +++ b/package.json @@ -190,6 +190,11 @@ "default": true, "description": "Enable hovers provided by the language server" }, + "clangd.followSymlinks": { + "type": "boolean", + "default": false, + "description": "Resolve navigation targets to canonical filesystem paths when possible (deduplicates editors when files are accessed via symlinks or junctions). Applies to file: URIs only." + }, "clangd.enable": { "type": "boolean", "default": true, diff --git a/src/clangd-context.ts b/src/clangd-context.ts index 4e51365..4c42fad 100644 --- a/src/clangd-context.ts +++ b/src/clangd-context.ts @@ -1,3 +1,4 @@ +import * as fs from 'fs'; import * as vscode from 'vscode'; import * as vscodelc from 'vscode-languageclient/node'; @@ -25,6 +26,163 @@ export function isClangdDocument(document: vscode.TextDocument) { return vscode.languages.match(clangdDocumentSelector, document); } +function createFollowSymlinksMiddleware(): vscodelc.Middleware { + const isLocationLink = (value: unknown): value is vscode.LocationLink => { + return !!value && typeof value === 'object' && 'targetUri' in value; + }; + + const resolveRealPath = async(uri: vscode.Uri): Promise => { + try { + const resolvedPath = await fs.promises.realpath(uri.fsPath); + return vscode.Uri.file(resolvedPath); + } catch { + return uri; + } + }; + + const canonicalizeUri = + async(uri: vscode.Uri, token: vscode.CancellationToken, + cache: Map): Promise => { + if (token.isCancellationRequested || uri.scheme !== 'file') + return uri; + const key = uri.toString(); + const cached = cache.get(key); + if (cached) + return cached; + const resolved = await resolveRealPath(uri); + cache.set(key, resolved); + return resolved; + }; + + const canonicalizeLocation = + async(location: vscode.Location, token: vscode.CancellationToken, + cache: Map): Promise => { + const newUri = await canonicalizeUri(location.uri, token, cache); + if (newUri.toString() === location.uri.toString()) + return location; + return new vscode.Location(newUri, location.range); + }; + + const canonicalizeLocationLink = + async(link: vscode.LocationLink, token: vscode.CancellationToken, + cache: Map): Promise => { + const newTargetUri = await canonicalizeUri(link.targetUri, token, cache); + if (newTargetUri.toString() === link.targetUri.toString()) + return link; + return { + originSelectionRange: link.originSelectionRange, + targetUri: newTargetUri, + targetRange: link.targetRange, + targetSelectionRange: link.targetSelectionRange, + }; + }; + + const canonicalizeNavigationResult = + async( + result: T|null|undefined, token: vscode.CancellationToken, + cache: Map): Promise => { + if (!result || token.isCancellationRequested) + return result; + if (Array.isArray(result)) { + if (result.length === 0) + return result; + if (isLocationLink(result[0])) { + const canonicalLinks: vscode.LocationLink[] = []; + for (const link of result as vscode.LocationLink[]) { + canonicalLinks.push( + await canonicalizeLocationLink(link, token, cache)); + } + return canonicalLinks as T; + } + const canonicalLocations: vscode.Location[] = []; + for (const location of result as vscode.Location[]) { + canonicalLocations.push( + await canonicalizeLocation(location, token, cache)); + } + return canonicalLocations as T; + } + const canonicalLocation = + await canonicalizeLocation(result as vscode.Location, token, cache); + return canonicalLocation as T; + }; + + const canonicalizeReferences = + async(result: vscode.Location[]|null|undefined, + token: vscode.CancellationToken, cache: Map): + Promise => { + if (!result || token.isCancellationRequested) + return result; + return Promise.all( + result.map(loc => canonicalizeLocation(loc, token, cache))); + }; + + const canonicalizeDocumentLinks = + async(links: vscode.DocumentLink[]|null|undefined, + token: vscode.CancellationToken, cache: Map): + Promise => { + if (!links || token.isCancellationRequested) + return links; + const updated: vscode.DocumentLink[] = []; + for (const link of links) { + if (!link.target) { + updated.push(link); + continue; + } + const newTarget = + await canonicalizeUri(link.target, token, cache); + if (newTarget.toString() !== link.target.toString()) + link.target = newTarget; + updated.push(link); + } + return updated; + }; + + return { + provideDefinition: async (document, position, token, next) => { + if (!next) + return null; + const cache = new Map(); + const result = await next(document, position, token); + return await canonicalizeNavigationResult(result, token, cache); + }, + provideTypeDefinition: async (document, position, token, next) => { + if (!next) + return null; + const cache = new Map(); + const result = await next(document, position, token); + return await canonicalizeNavigationResult(result, token, cache); + }, + provideImplementation: async (document, position, token, next) => { + if (!next) + return null; + const cache = new Map(); + const result = await next(document, position, token); + return await canonicalizeNavigationResult(result, token, cache); + }, + provideDeclaration: async (document, position, token, next) => { + if (!next) + return null; + const cache = new Map(); + const result = await next(document, position, token); + return await canonicalizeNavigationResult(result, token, cache); + }, + provideReferences: async (document, position, context, token, next) => { + if (!next) + return null; + const cache = new Map(); + const result = await next(document, position, context, token); + return await canonicalizeReferences(result, token, cache); + }, + provideDocumentLinks: async (document, token, next) => { + if (!next) + return null; + const cache = new Map(); + const links = await next(document, token); + return await canonicalizeDocumentLinks(links, token, cache); + } + }; +} + class ClangdLanguageClient extends vscodelc.LanguageClient { // Override the default implementation for failed requests. The default // behavior is just to log failures in the output panel, however output panel @@ -102,6 +260,97 @@ export class ClangdContext implements vscode.Disposable { } const serverOptions: vscodelc.ServerOptions = clangd; + // We hack up the completion items a bit to prevent VSCode from re-ranking + // and throwing away all our delicious signals like type information. + // + // VSCode sorts by (fuzzymatch(prefix, item.filterText), item.sortText) + // By adding the prefix to the beginning of the filterText, we get a + // perfect + // fuzzymatch score for every item. + // The sortText (which reflects clangd ranking) breaks the tie. + // This also prevents VSCode from filtering out any results due to the + // differences in how fuzzy filtering is applies, e.g. enable dot-to-arrow + // fixes in completion. + // + // We also mark the list as incomplete to force retrieving new rankings. + // See https://github.com/microsoft/language-server-protocol/issues/898 + const middleware: vscodelc.Middleware = { + provideCompletionItem: async (document, position, context, token, + next) => { + if (!await config.get('enableCodeCompletion')) + return new vscode.CompletionList([], /*isIncomplete=*/ false); + let list = await next(document, position, context, token); + if (!await config.get('serverCompletionRanking')) + return list; + let items = (!list ? [] : Array.isArray(list) ? list : list.items); + items = items.map(item => { + // Gets the prefix used by VSCode when doing fuzzymatch. + let prefix = document.getText( + new vscode.Range((item.range as vscode.Range).start, position)) + if (prefix) + item.filterText = prefix + '_' + item.filterText; + // Workaround for https://github.com/clangd/vscode-clangd/issues/357 + // clangd's used of commit-characters was well-intentioned, but + // overall UX is poor. Due to vscode-languageclient bugs, we didn't + // notice until the behavior was in several releases, so we need + // to override it on the client. + item.commitCharacters = []; + // VSCode won't automatically trigger signature help when entering + // a placeholder, e.g. if the completion inserted brackets and + // placed the cursor inside them. + // https://github.com/microsoft/vscode/issues/164310 + // They say a plugin should trigger this, but LSP has no mechanism. + // https://github.com/microsoft/language-server-protocol/issues/274 + // (This workaround is incomplete, and only helps the first param). + if (item.insertText instanceof vscode.SnippetString && + !item.command && + item.insertText.value.match(/[([{<,] ?\$\{?[01]\D/)) + item.command = { + title: 'Signature help', + command: 'editor.action.triggerParameterHints' + }; + return item; + }) + return new vscode.CompletionList(items, /*isIncomplete=*/ true); + }, + provideHover: async (document, position, token, next) => { + if (!await config.get('enableHover')) + return null; + return next(document, position, token); + }, + // VSCode applies fuzzy match only on the symbol name, thus it throws + // away all results if query token is a prefix qualified name. + // By adding the containerName to the symbol name, it prevents VSCode + // from filtering out any results, e.g. enable workspaceSymbols for + // qualified symbols. + provideWorkspaceSymbols: async (query, token, next) => { + let symbols = await next(query, token); + return symbols?.map(symbol => { + // Only make this adjustment if the query is in fact qualified. + // Otherwise, we get a suboptimal ordering of results because + // including the name's qualifier (if it has one) in symbol.name + // means vscode can no longer tell apart exact matches from + // partial matches. + if (query.includes('::')) { + if (symbol.containerName) + symbol.name = `${symbol.containerName}::${symbol.name}`; + // results from clangd strip the leading '::', so vscode fuzzy + // match will filter out all results unless we add prefix back in + if (query.startsWith('::')) { + symbol.name = `::${symbol.name}`; + } + // Clean the containerName to avoid displaying it twice. + symbol.containerName = ''; + } + return symbol; + }) + }, + }; + + if (await config.get('followSymlinks')) { + Object.assign(middleware, createFollowSymlinksMiddleware()); + } + const clientOptions: vscodelc.LanguageClientOptions = { // Register the server for c-family and cuda files. documentSelector: clangdDocumentSelector, @@ -112,93 +361,7 @@ export class ClangdContext implements vscode.Disposable { outputChannel: outputChannel, // Do not switch to output window when clangd returns output. revealOutputChannelOn: vscodelc.RevealOutputChannelOn.Never, - - // We hack up the completion items a bit to prevent VSCode from re-ranking - // and throwing away all our delicious signals like type information. - // - // VSCode sorts by (fuzzymatch(prefix, item.filterText), item.sortText) - // By adding the prefix to the beginning of the filterText, we get a - // perfect - // fuzzymatch score for every item. - // The sortText (which reflects clangd ranking) breaks the tie. - // This also prevents VSCode from filtering out any results due to the - // differences in how fuzzy filtering is applies, e.g. enable dot-to-arrow - // fixes in completion. - // - // We also mark the list as incomplete to force retrieving new rankings. - // See https://github.com/microsoft/language-server-protocol/issues/898 - middleware: { - provideCompletionItem: async (document, position, context, token, - next) => { - if (!await config.get('enableCodeCompletion')) - return new vscode.CompletionList([], /*isIncomplete=*/ false); - let list = await next(document, position, context, token); - if (!await config.get('serverCompletionRanking')) - return list; - let items = (!list ? [] : Array.isArray(list) ? list : list.items); - items = items.map(item => { - // Gets the prefix used by VSCode when doing fuzzymatch. - let prefix = document.getText( - new vscode.Range((item.range as vscode.Range).start, position)) - if (prefix) - item.filterText = prefix + '_' + item.filterText; - // Workaround for https://github.com/clangd/vscode-clangd/issues/357 - // clangd's used of commit-characters was well-intentioned, but - // overall UX is poor. Due to vscode-languageclient bugs, we didn't - // notice until the behavior was in several releases, so we need - // to override it on the client. - item.commitCharacters = []; - // VSCode won't automatically trigger signature help when entering - // a placeholder, e.g. if the completion inserted brackets and - // placed the cursor inside them. - // https://github.com/microsoft/vscode/issues/164310 - // They say a plugin should trigger this, but LSP has no mechanism. - // https://github.com/microsoft/language-server-protocol/issues/274 - // (This workaround is incomplete, and only helps the first param). - if (item.insertText instanceof vscode.SnippetString && - !item.command && - item.insertText.value.match(/[([{<,] ?\$\{?[01]\D/)) - item.command = { - title: 'Signature help', - command: 'editor.action.triggerParameterHints' - }; - return item; - }) - return new vscode.CompletionList(items, /*isIncomplete=*/ true); - }, - provideHover: async (document, position, token, next) => { - if (!await config.get('enableHover')) - return null; - return next(document, position, token); - }, - // VSCode applies fuzzy match only on the symbol name, thus it throws - // away all results if query token is a prefix qualified name. - // By adding the containerName to the symbol name, it prevents VSCode - // from filtering out any results, e.g. enable workspaceSymbols for - // qualified symbols. - provideWorkspaceSymbols: async (query, token, next) => { - let symbols = await next(query, token); - return symbols?.map(symbol => { - // Only make this adjustment if the query is in fact qualified. - // Otherwise, we get a suboptimal ordering of results because - // including the name's qualifier (if it has one) in symbol.name - // means vscode can no longer tell apart exact matches from - // partial matches. - if (query.includes('::')) { - if (symbol.containerName) - symbol.name = `${symbol.containerName}::${symbol.name}`; - // results from clangd strip the leading '::', so vscode fuzzy - // match will filter out all results unless we add prefix back in - if (query.startsWith('::')) { - symbol.name = `::${symbol.name}`; - } - // Clean the containerName to avoid displaying it twice. - symbol.containerName = ''; - } - return symbol; - }) - }, - }, + middleware, }; const client = new ClangdLanguageClient('Clang Language Server',