Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
337 changes: 250 additions & 87 deletions src/clangd-context.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as fs from 'fs';
import * as vscode from 'vscode';
import * as vscodelc from 'vscode-languageclient/node';

Expand Down Expand Up @@ -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<vscode.Uri> => {
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<string, vscode.Uri>): Promise<vscode.Uri> => {
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<string, vscode.Uri>): Promise<vscode.Location> => {
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<string, vscode.Uri>): Promise<vscode.LocationLink> => {
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<T extends vscode.Location|vscode.Location[]|vscode.LocationLink[]>(
result: T|null|undefined, token: vscode.CancellationToken,
cache: Map<string, vscode.Uri>): Promise<T|null|undefined> => {
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<string, vscode.Uri>):
Promise<vscode.Location[]|null|undefined> => {
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<string, vscode.Uri>):
Promise<vscode.DocumentLink[]|null|undefined> => {
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<string, vscode.Uri>();
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<string, vscode.Uri>();
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<string, vscode.Uri>();
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<string, vscode.Uri>();
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<string, vscode.Uri>();
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<string, vscode.Uri>();
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
Expand Down Expand Up @@ -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<boolean>('enableCodeCompletion'))
return new vscode.CompletionList([], /*isIncomplete=*/ false);
let list = await next(document, position, context, token);
if (!await config.get<boolean>('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<boolean>('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<boolean>('followSymlinks')) {
Object.assign(middleware, createFollowSymlinksMiddleware());
}

const clientOptions: vscodelc.LanguageClientOptions = {
// Register the server for c-family and cuda files.
documentSelector: clangdDocumentSelector,
Expand All @@ -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<boolean>('enableCodeCompletion'))
return new vscode.CompletionList([], /*isIncomplete=*/ false);
let list = await next(document, position, context, token);
if (!await config.get<boolean>('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<boolean>('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',
Expand Down