"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PluginHost = void 0;
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 (this.canSkipDiagnostics(document)) {
            // 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 (await this.execute('getDiagnostics', [document, cancellationToken], ExecuteMode.Collect, 'high')).flat();
    }
    canSkipDiagnostics(document) {
        return ((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\\')));
    }
    async getDiagnosticsForPullMode(textDocument, previousResultId, cancellationToken) {
        const document = this.getDocument(textDocument.uri);
        let previousResultIdData = {};
        if (previousResultId) {
            try {
                previousResultIdData = JSON.parse(previousResultId);
            }
            catch (error) { }
        }
        if (this.canSkipDiagnostics(document)) {
            // Don't return diagnostics for files inside node_modules. These are considered read-only (cannot be changed)
            return {
                kind: 'full',
                items: []
            };
        }
        const plugins = this.plugins.filter((plugin) => typeof plugin.getDiagnosticsForPullMode === 'function');
        const results = await Promise.all(plugins.map(async (plugin) => [
            plugin.__name,
            await this.tryExecutePlugin(plugin, 'getDiagnosticsForPullMode', [document, previousResultIdData[plugin.__name], cancellationToken], { kind: 'full', items: [] })
        ]));
        const newResultId = JSON.stringify(Object.fromEntries(results.map(([name, res]) => [name, res.resultId])));
        const unchanged = results
            .filter(([, res]) => res.kind === 'unchanged')
            .map(([name]) => name);
        const fullResults = results.flatMap(([, res]) => (res.kind === 'full' ? res.items : []));
        if (unchanged.length === plugins.length) {
            return {
                kind: 'unchanged',
                resultId: newResultId
            };
        }
        if (unchanged.length === 0) {
            return {
                kind: 'full',
                resultId: newResultId,
                items: fullResults
            };
        }
        const unchangedPlugins = plugins.filter((plugin) => unchanged.includes(plugin.__name));
        const unchangedResults = await Promise.all(unchangedPlugins.map(async (plugin) => {
            const result = await this.tryExecutePlugin(plugin, 'getDiagnostics', [document, cancellationToken], []);
            return result;
        }));
        return {
            kind: 'full',
            resultId: newResultId,
            items: fullResults.concat(unchangedResults.flat())
        };
    }
    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 = completions.map((completion) => completion.result.items).flat();
        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 (await this.execute('formatDocument', [document, options], ExecuteMode.Collect, 'high')).flat();
    }
    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);
        const result = await this.execute('getDocumentColors', [document], ExecuteMode.Collect, 'low');
        return result?.flat() ?? [];
    }
    async getColorPresentations(textDocument, range, color) {
        const document = this.getDocument(textDocument.uri);
        return (await this.execute('getColorPresentations', [document, range, color], ExecuteMode.Collect, 'high')).flat();
    }
    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 (await this.execute('getDocumentSymbols', [document, cancellationToken], ExecuteMode.Collect, 'high')).flat();
    }
    comparePosition(pos1, pos2) {
        if (pos1.line < pos2.line)
            return -1;
        if (pos1.line > pos2.line)
            return 1;
        if (pos1.character < pos2.character)
            return -1;
        if (pos1.character > pos2.character)
            return 1;
        return 0;
    }
    rangeContains(parent, child) {
        return (this.comparePosition(parent.start, child.start) <= 0 &&
            this.comparePosition(child.end, parent.end) <= 0);
    }
    async getHierarchicalDocumentSymbols(textDocument, cancellationToken) {
        const flat = await this.getDocumentSymbols(textDocument, cancellationToken);
        const symbols = flat
            .map((s) => vscode_languageserver_1.DocumentSymbol.create(s.name, undefined, s.kind, s.location.range, s.location.range, []))
            .sort((a, b) => {
            const start = this.comparePosition(a.range.start, b.range.start);
            if (start !== 0)
                return start;
            return this.comparePosition(b.range.end, a.range.end);
        });
        const stack = [];
        const roots = [];
        for (const node of symbols) {
            while (stack.length > 0 && !this.rangeContains(stack.at(-1).range, node.range)) {
                stack.pop();
            }
            if (stack.length > 0) {
                stack.at(-1).children.push(node);
            }
            else {
                roots.push(node);
            }
            stack.push(node);
        }
        return roots;
    }
    async getDefinitions(textDocument, position) {
        const document = this.getDocument(textDocument.uri);
        const definitions = (await this.execute('getDefinitions', [document, position], ExecuteMode.Collect, 'high')).flat();
        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 = (await this.execute('getCodeActions', [document, range, context, cancellationToken], ExecuteMode.Collect, 'high')).flat();
        // 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 result?.filter(Boolean).flat();
    }
    async getFoldingRanges(textDocument) {
        const document = this.getDocument(textDocument.uri);
        const result = (await this.execute('getFoldingRanges', [document], ExecuteMode.Collect, 'high')).flat();
        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