svelte-language-server
Version:
A language server for Svelte
406 lines • 20.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PluginHost = void 0;
const lodash_1 = require("lodash");
const perf_hooks_1 = require("perf_hooks");
const vscode_languageserver_1 = require("vscode-languageserver");
const documents_1 = require("../lib/documents");
const logger_1 = require("../logger");
const utils_1 = require("../utils");
var ExecuteMode;
(function (ExecuteMode) {
ExecuteMode[ExecuteMode["None"] = 0] = "None";
ExecuteMode[ExecuteMode["FirstNonNull"] = 1] = "FirstNonNull";
ExecuteMode[ExecuteMode["Collect"] = 2] = "Collect";
})(ExecuteMode || (ExecuteMode = {}));
class PluginHost {
constructor(documentsManager) {
this.documentsManager = documentsManager;
this.plugins = [];
this.pluginHostConfig = {
filterIncompleteCompletions: true,
definitionLinkSupport: false
};
this.deferredRequests = {};
this.requestTimings = {};
}
initialize(pluginHostConfig) {
this.pluginHostConfig = pluginHostConfig;
}
register(plugin) {
this.plugins.push(plugin);
}
didUpdateDocument() {
this.deferredRequests = {};
}
async getDiagnostics(textDocument, cancellationToken) {
const document = this.getDocument(textDocument.uri);
if ((document.getFilePath()?.includes('/node_modules/') ||
document.getFilePath()?.includes('\\node_modules\\')) &&
// Sapper convention: Put stuff inside node_modules below src
!(document.getFilePath()?.includes('/src/node_modules/') ||
document.getFilePath()?.includes('\\src\\node_modules\\'))) {
// Don't return diagnostics for files inside node_modules. These are considered read-only (cannot be changed)
// and in case of svelte-check they would pollute/skew the output
return [];
}
return (0, lodash_1.flatten)(await this.execute('getDiagnostics', [document, cancellationToken], ExecuteMode.Collect, 'high'));
}
async doHover(textDocument, position) {
const document = this.getDocument(textDocument.uri);
return this.execute('doHover', [document, position], ExecuteMode.FirstNonNull, 'high');
}
async getCompletions(textDocument, position, completionContext, cancellationToken) {
const document = this.getDocument(textDocument.uri);
const completions = await Promise.all(this.plugins.map(async (plugin) => {
const result = await this.tryExecutePlugin(plugin, 'getCompletions', [document, position, completionContext, cancellationToken], null);
if (result) {
return { result: result, plugin: plugin.__name };
}
})).then((completions) => completions.filter(utils_1.isNotNullOrUndefined));
const html = completions.find((completion) => completion.plugin === 'html');
const ts = completions.find((completion) => completion.plugin === 'ts');
if (html && ts && (0, documents_1.getNodeIfIsInHTMLStartTag)(document.html, document.offsetAt(position))) {
// Completion in a component or html start tag and both html and ts
// suggest something -> filter out all duplicates from TS completions
const htmlCompletions = new Set(html.result.items.map((item) => item.label));
ts.result.items = ts.result.items.filter((item) => {
const label = item.label;
if (htmlCompletions.has(label)) {
return false;
}
if (label[0] === '"' && label[label.length - 1] === '"') {
// this will result in a wrong completion regardless, remove the quotes
item.label = item.label.slice(1, -1);
if (htmlCompletions.has(item.label)) {
// "aria-label" -> aria-label -> exists in html completions
return false;
}
}
if (label.startsWith('on')) {
if (htmlCompletions.has('on:' + label.slice(2))) {
// onclick -> on:click -> exists in html completions
return false;
}
}
// adjust sort text so it does appear after html completions
item.sortText = 'Z' + (item.sortText || '');
return true;
});
}
let itemDefaults;
if (completions.length === 1) {
itemDefaults = completions[0]?.result.itemDefaults;
}
else {
// don't apply items default to the result of other plugins
for (const completion of completions) {
const itemDefaults = completion.result.itemDefaults;
if (!itemDefaults) {
continue;
}
completion.result.items.forEach((item) => {
item.commitCharacters ??= itemDefaults.commitCharacters;
});
}
}
let flattenedCompletions = (0, lodash_1.flatten)(completions.map((completion) => completion.result.items));
const isIncomplete = completions.reduce((incomplete, completion) => incomplete || completion.result.isIncomplete, false);
// If the result is incomplete, we need to filter the results ourselves
// to throw out non-matching results. VSCode does filter client-side,
// but other IDEs might not.
if (isIncomplete && this.pluginHostConfig.filterIncompleteCompletions) {
const offset = document.offsetAt(position);
// Assumption for performance reasons:
// Noone types import names longer than 20 characters and still expects perfect autocompletion.
const text = document.getText().substring(Math.max(0, offset - 20), offset);
const start = (0, utils_1.regexLastIndexOf)(text, /[\W\s]/g) + 1;
const filterValue = text.substring(start).toLowerCase();
flattenedCompletions = flattenedCompletions.filter((comp) => comp.label.toLowerCase().includes(filterValue));
}
const result = vscode_languageserver_1.CompletionList.create(flattenedCompletions, isIncomplete);
result.itemDefaults = itemDefaults;
return result;
}
async resolveCompletion(textDocument, completionItem, cancellationToken) {
const document = this.getDocument(textDocument.uri);
const result = await this.execute('resolveCompletion', [document, completionItem, cancellationToken], ExecuteMode.FirstNonNull, 'high');
return result ?? completionItem;
}
async formatDocument(textDocument, options) {
const document = this.getDocument(textDocument.uri);
return (0, lodash_1.flatten)(await this.execute('formatDocument', [document, options], ExecuteMode.Collect, 'high'));
}
async doTagComplete(textDocument, position) {
const document = this.getDocument(textDocument.uri);
return this.execute('doTagComplete', [document, position], ExecuteMode.FirstNonNull, 'high');
}
async getDocumentColors(textDocument) {
const document = this.getDocument(textDocument.uri);
return (0, lodash_1.flatten)(await this.execute('getDocumentColors', [document], ExecuteMode.Collect, 'low'));
}
async getColorPresentations(textDocument, range, color) {
const document = this.getDocument(textDocument.uri);
return (0, lodash_1.flatten)(await this.execute('getColorPresentations', [document, range, color], ExecuteMode.Collect, 'high'));
}
async getDocumentSymbols(textDocument, cancellationToken) {
const document = this.getDocument(textDocument.uri);
// VSCode requested document symbols twice for the outline view and the sticky scroll
// Manually delay here and don't use low priority as one of them will return no symbols
await new Promise((resolve) => setTimeout(resolve, 1000));
if (cancellationToken.isCancellationRequested) {
return [];
}
return (0, lodash_1.flatten)(await this.execute('getDocumentSymbols', [document, cancellationToken], ExecuteMode.Collect, 'high'));
}
async getDefinitions(textDocument, position) {
const document = this.getDocument(textDocument.uri);
const definitions = (0, lodash_1.flatten)(await this.execute('getDefinitions', [document, position], ExecuteMode.Collect, 'high'));
if (this.pluginHostConfig.definitionLinkSupport) {
return definitions;
}
else {
return definitions.map((def) => ({ range: def.targetSelectionRange, uri: def.targetUri }));
}
}
async getCodeActions(textDocument, range, context, cancellationToken) {
const document = this.getDocument(textDocument.uri);
const actions = (0, lodash_1.flatten)(await this.execute('getCodeActions', [document, range, context, cancellationToken], ExecuteMode.Collect, 'high'));
// Sort Svelte actions below other actions as they are often less relevant
actions.sort((a, b) => {
const aPrio = a.title.startsWith('(svelte)') ? 1 : 0;
const bPrio = b.title.startsWith('(svelte)') ? 1 : 0;
return aPrio - bPrio;
});
return actions;
}
async executeCommand(textDocument, command, args) {
const document = this.getDocument(textDocument.uri);
return await this.execute('executeCommand', [document, command, args], ExecuteMode.FirstNonNull, 'high');
}
async resolveCodeAction(textDocument, codeAction, cancellationToken) {
const document = this.getDocument(textDocument.uri);
const result = await this.execute('resolveCodeAction', [document, codeAction, cancellationToken], ExecuteMode.FirstNonNull, 'high');
return result ?? codeAction;
}
async updateImports(fileRename) {
return await this.execute('updateImports', [fileRename], ExecuteMode.FirstNonNull, 'high');
}
async prepareRename(textDocument, position) {
const document = this.getDocument(textDocument.uri);
return await this.execute('prepareRename', [document, position], ExecuteMode.FirstNonNull, 'high');
}
async rename(textDocument, position, newName) {
const document = this.getDocument(textDocument.uri);
return await this.execute('rename', [document, position, newName], ExecuteMode.FirstNonNull, 'high');
}
async findReferences(textDocument, position, context, cancellationToken) {
const document = this.getDocument(textDocument.uri);
return await this.execute('findReferences', [document, position, context, cancellationToken], ExecuteMode.FirstNonNull, 'high');
}
async fileReferences(uri) {
return await this.execute('fileReferences', [uri], ExecuteMode.FirstNonNull, 'high');
}
async findComponentReferences(uri) {
return await this.execute('findComponentReferences', [uri], ExecuteMode.FirstNonNull, 'high');
}
async getSignatureHelp(textDocument, position, context, cancellationToken) {
const document = this.getDocument(textDocument.uri);
return await this.execute('getSignatureHelp', [document, position, context, cancellationToken], ExecuteMode.FirstNonNull, 'high');
}
/**
* The selection range supports multiple cursors,
* each position should return its own selection range tree like `Array.map`.
* Quote the LSP spec
* > A selection range in the return array is for the position in the provided parameters at the same index. Therefore positions[i] must be contained in result[i].range.
* @see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_selectionRange
*
* Making PluginHost implement the same interface would make it quite hard to get
* the corresponding selection range of each position from different plugins.
* Therefore the special treatment here.
*/
async getSelectionRanges(textDocument, positions) {
const document = this.getDocument(textDocument.uri);
try {
return Promise.all(positions.map(async (position) => {
for (const plugin of this.plugins) {
const range = await plugin.getSelectionRange?.(document, position);
if (range) {
return range;
}
}
return vscode_languageserver_1.SelectionRange.create(vscode_languageserver_1.Range.create(position, position));
}));
}
catch (error) {
logger_1.Logger.error(error);
return null;
}
}
async getSemanticTokens(textDocument, range, cancellationToken) {
const document = this.getDocument(textDocument.uri);
return await this.execute('getSemanticTokens', [document, range, cancellationToken], ExecuteMode.FirstNonNull, 'smart');
}
async getLinkedEditingRanges(textDocument, position) {
const document = this.getDocument(textDocument.uri);
return await this.execute('getLinkedEditingRanges', [document, position], ExecuteMode.FirstNonNull, 'high');
}
getImplementation(textDocument, position, cancellationToken) {
const document = this.getDocument(textDocument.uri);
return this.execute('getImplementation', [document, position, cancellationToken], ExecuteMode.FirstNonNull, 'high');
}
getTypeDefinition(textDocument, position) {
const document = this.getDocument(textDocument.uri);
return this.execute('getTypeDefinition', [document, position], ExecuteMode.FirstNonNull, 'high');
}
getInlayHints(textDocument, range, cancellationToken) {
const document = this.getDocument(textDocument.uri);
return this.execute('getInlayHints', [document, range, cancellationToken], ExecuteMode.FirstNonNull, 'smart');
}
prepareCallHierarchy(textDocument, position, cancellationToken) {
const document = this.getDocument(textDocument.uri);
return this.execute('prepareCallHierarchy', [document, position, cancellationToken], ExecuteMode.FirstNonNull, 'high');
}
getIncomingCalls(item, cancellationToken) {
return this.execute('getIncomingCalls', [item, cancellationToken], ExecuteMode.FirstNonNull, 'high');
}
getOutgoingCalls(item, cancellationToken) {
return this.execute('getOutgoingCalls', [item, cancellationToken], ExecuteMode.FirstNonNull, 'high');
}
async getCodeLens(textDocument) {
const document = this.getDocument(textDocument.uri);
if (!document) {
throw new Error('Cannot call methods on an unopened document');
}
const result = await this.execute('getCodeLens', [document], ExecuteMode.Collect, 'smart');
return (0, lodash_1.flatten)(result.filter(Boolean));
}
async getFoldingRanges(textDocument) {
const document = this.getDocument(textDocument.uri);
const result = (0, lodash_1.flatten)(await this.execute('getFoldingRanges', [document], ExecuteMode.Collect, 'high'));
return result;
}
async resolveCodeLens(textDocument, codeLens, cancellationToken) {
const document = this.getDocument(textDocument.uri);
if (!document) {
throw new Error('Cannot call methods on an unopened document');
}
return ((await this.execute('resolveCodeLens', [document, codeLens, cancellationToken], ExecuteMode.FirstNonNull, 'smart')) ?? codeLens);
}
findDocumentHighlight(textDocument, position) {
const document = this.getDocument(textDocument.uri);
if (!document) {
throw new Error('Cannot call methods on an unopened document');
}
return (this.execute('findDocumentHighlight', [document, position], ExecuteMode.FirstNonNull, 'high') ?? [] // fall back to empty array to prevent fallback to word-based highlighting
);
}
async getWorkspaceSymbols(query, token) {
return await this.execute('getWorkspaceSymbols', [query, token], ExecuteMode.FirstNonNull, 'high');
}
onWatchFileChanges(onWatchFileChangesParas) {
for (const support of this.plugins) {
support.onWatchFileChanges?.(onWatchFileChangesParas);
}
}
updateTsOrJsFile(fileName, changes) {
for (const support of this.plugins) {
support.updateTsOrJsFile?.(fileName, changes);
}
}
getDocument(uri) {
const document = this.documentsManager.get(uri);
if (!document) {
throw new Error('Cannot call methods on an unopened document');
}
return document;
}
async execute(name, args, mode, priority) {
const plugins = this.plugins.filter((plugin) => typeof plugin[name] === 'function');
// Priority 'smart' tries to aproximate how much time a method takes to execute,
// making it low priority if it takes too long or if it seems like other methods do.
const now = perf_hooks_1.performance.now();
if (priority === 'smart' &&
(this.requestTimings[name]?.[0] > 500 ||
Object.values(this.requestTimings).filter((t) => t[0] > 400 && now - t[1] < 60 * 1000).length > 2)) {
logger_1.Logger.debug(`Executing next invocation of "${name}" with low priority`);
priority = 'low';
if (this.requestTimings[name]) {
this.requestTimings[name][0] = this.requestTimings[name][0] / 2 + 150;
}
}
if (priority === 'low') {
// If a request doesn't have priority, we first wait 1 second to
// 1. let higher priority requests get through first
// 2. wait for possible document changes, which make the request wait again
// Due to waiting, low priority items should preferrably be those who do not
// rely on positions or ranges and rather on the whole document only.
const debounce = async () => {
const id = Math.random();
this.deferredRequests[name] = [
id,
new Promise((resolve, reject) => {
setTimeout(() => {
if (!this.deferredRequests[name] ||
this.deferredRequests[name][0] === id) {
resolve();
}
else {
// We should not get into this case. According to the spec,
// the language client does not send another request
// of the same type until the previous one is answered.
reject();
}
}, 1000);
})
];
try {
await this.deferredRequests[name][1];
if (!this.deferredRequests[name]) {
return debounce();
}
return true;
}
catch (e) {
return false;
}
};
const shouldContinue = await debounce();
if (!shouldContinue) {
return;
}
}
const startTime = perf_hooks_1.performance.now();
const result = await this.executePlugins(name, args, mode, plugins);
this.requestTimings[name] = [perf_hooks_1.performance.now() - startTime, startTime];
return result;
}
async executePlugins(name, args, mode, plugins) {
switch (mode) {
case ExecuteMode.FirstNonNull:
for (const plugin of plugins) {
const res = await this.tryExecutePlugin(plugin, name, args, null);
if (res != null) {
return res;
}
}
return null;
case ExecuteMode.Collect:
return Promise.all(plugins.map((plugin) => this.tryExecutePlugin(plugin, name, args, [])));
case ExecuteMode.None:
await Promise.all(plugins.map((plugin) => this.tryExecutePlugin(plugin, name, args, null)));
return;
}
}
async tryExecutePlugin(plugin, fnName, args, failValue) {
try {
return await plugin[fnName](...args);
}
catch (e) {
logger_1.Logger.error(e);
return failValue;
}
}
}
exports.PluginHost = PluginHost;
//# sourceMappingURL=PluginHost.js.map