UNPKG

svelte-language-server

Version:
406 lines 20.8 kB
"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