"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
Object.defineProperty(exports, "__esModule", { value: true });
const Process = require("child_process");
const Path = require("path");
const TurndownService = require("turndown");
const LSP = require("vscode-languageserver");
const vscode_languageserver_textdocument_1 = require("vscode-languageserver-textdocument");
const analyser_1 = require("./analyser");
const Builtins = require("./builtins");
const config = require("./config");
const executables_1 = require("./executables");
const linter_1 = require("./linter");
const parser_1 = require("./parser");
const ReservedWords = require("./reservedWords");
const types_1 = require("./types");
const array_1 = require("./util/array");
const sh_1 = require("./util/sh");
const PARAMETER_EXPANSION_PREFIXES = new Set(['$', '${']);
/**
 * The BashServer glues together the separate components to implement
 * the various parts of the Language Server Protocol.
 */
class BashServer {
    /**
     * Initialize the server based on a connection to the client and the protocols
     * initialization parameters.
     */
    static initialize(connection, { rootPath, capabilities }) {
        return __awaiter(this, void 0, void 0, function* () {
            const { PATH } = process.env;
            if (!PATH) {
                throw new Error('Expected PATH environment variable to be set');
            }
            const parser = yield (0, parser_1.initializeParser)();
            const analyzer = new analyser_1.default({ console: connection.console, parser });
            let backgroundAnalysisCompleted;
            if (rootPath) {
                // NOTE: we do not block the server initialization on this background analysis.
                backgroundAnalysisCompleted = analyzer.initiateBackgroundAnalysis({ rootPath });
            }
            const shellcheckPath = config.getShellcheckPath();
            const linter = new linter_1.Linter({
                console: connection.console,
                executablePath: shellcheckPath,
            });
            if (!shellcheckPath) {
                connection.console.info('ShellCheck linting is disabled.');
            }
            const executables = yield executables_1.default.fromPath(PATH);
            return new BashServer({
                analyzer,
                backgroundAnalysisCompleted,
                capabilities,
                connection,
                executables,
                linter,
            });
        });
    }
    constructor({ connection, executables, analyzer, linter, capabilities, backgroundAnalysisCompleted, }) {
        this.documents = new LSP.TextDocuments(vscode_languageserver_textdocument_1.TextDocument);
        this.connection = connection;
        this.executables = executables;
        this.analyzer = analyzer;
        this.linter = linter;
        this.clientCapabilities = capabilities;
        this.backgroundAnalysisCompleted = backgroundAnalysisCompleted;
    }
    /**
     * Register handlers for the events from the Language Server Protocol that we
     * care about.
     */
    register(connection) {
        // The content of a text document has changed. This event is emitted
        // when the text document first opened or when its content has changed.
        this.documents.listen(this.connection);
        this.documents.onDidChangeContent((change) => __awaiter(this, void 0, void 0, function* () {
            var _a;
            const { uri } = change.document;
            // Load the tree for the modified contents into the analyzer:
            const analyzeDiagnostics = this.analyzer.analyze(uri, change.document);
            // Run shellcheck diagnostics:
            let diagnostics = [];
            const folders = ((_a = this.clientCapabilities.workspace) === null || _a === void 0 ? void 0 : _a.workspaceFolders)
                ? yield connection.workspace.getWorkspaceFolders()
                : [];
            const lintDiagnostics = yield this.linter.lint(change.document, folders || []);
            diagnostics = diagnostics.concat(lintDiagnostics);
            // Treesitter's diagnostics can be a bit inaccurate, so we only merge the
            // analyzer's diagnostics if the setting is enabled:
            if (config.getHighlightParsingError()) {
                diagnostics = diagnostics.concat(analyzeDiagnostics);
            }
            connection.sendDiagnostics({ uri, diagnostics });
        }));
        // Register all the handlers for the LSP events.
        connection.onHover(this.onHover.bind(this));
        connection.onDefinition(this.onDefinition.bind(this));
        connection.onDocumentSymbol(this.onDocumentSymbol.bind(this));
        connection.onWorkspaceSymbol(this.onWorkspaceSymbol.bind(this));
        connection.onDocumentHighlight(this.onDocumentHighlight.bind(this));
        connection.onReferences(this.onReferences.bind(this));
        connection.onCompletion(this.onCompletion.bind(this));
        connection.onCompletionResolve(this.onCompletionResolve.bind(this));
        // FIXME: re-lint on workspace folder change
    }
    /**
     * The parts of the Language Server Protocol that we are currently supporting.
     */
    capabilities() {
        return {
            // For now we're using full-sync even though tree-sitter has great support
            // for partial updates.
            textDocumentSync: LSP.TextDocumentSyncKind.Full,
            completionProvider: {
                resolveProvider: true,
                triggerCharacters: ['$', '{'],
            },
            hoverProvider: true,
            documentHighlightProvider: true,
            definitionProvider: true,
            documentSymbolProvider: true,
            workspaceSymbolProvider: true,
            referencesProvider: true,
        };
    }
    getWordAtPoint(params) {
        return this.analyzer.wordAtPoint(params.textDocument.uri, params.position.line, params.position.character);
    }
    getCommandNameAtPoint(params) {
        return this.analyzer.commandNameAtPoint(params.textDocument.uri, params.position.line, params.position.character);
    }
    logRequest({ request, params, word, }) {
        const wordLog = word ? `"${word}"` : 'null';
        this.connection.console.log(`${request} ${params.position.line}:${params.position.character} word=${wordLog}`);
    }
    getDocumentationForSymbol({ currentUri, symbol, }) {
        const symbolUri = symbol.location.uri;
        const symbolStarLine = symbol.location.range.start.line;
        const commentAboveSymbol = this.analyzer.commentsAbove(symbolUri, symbolStarLine);
        const symbolDocumentation = commentAboveSymbol ? `\n\n${commentAboveSymbol}` : '';
        const hoverHeader = `### ${symbolKindToDescription(symbol.kind)}: **${symbol.name}**`;
        const symbolLocation = symbolUri !== currentUri
            ? `in ${Path.relative(currentUri, symbolUri)}`
            : `on line ${symbolStarLine + 1}`;
        return `${hoverHeader} - *defined ${symbolLocation}*${symbolDocumentation}`;
    }
    getCompletionItemsForSymbols({ symbols, currentUri, }) {
        return deduplicateSymbols({ symbols, currentUri }).map((symbol) => ({
            label: symbol.name,
            kind: symbolKindToCompletionKind(symbol.kind),
            data: {
                name: symbol.name,
                type: types_1.CompletionItemDataType.Symbol,
            },
            documentation: symbol.location.uri !== currentUri
                ? this.getDocumentationForSymbol({
                    currentUri,
                    symbol,
                })
                : undefined,
        }));
    }
    onHover(params) {
        return __awaiter(this, void 0, void 0, function* () {
            const word = this.getWordAtPoint(params);
            const currentUri = params.textDocument.uri;
            this.logRequest({ request: 'onHover', params, word });
            if (!word || word.startsWith('#')) {
                return null;
            }
            const explainshellEndpoint = config.getExplainshellEndpoint();
            if (explainshellEndpoint) {
                try {
                    const { helpHTML } = yield this.analyzer.getExplainshellDocumentation({
                        params,
                        endpoint: explainshellEndpoint,
                    });
                    if (helpHTML) {
                        return {
                            contents: {
                                kind: 'markdown',
                                value: new TurndownService().turndown(helpHTML),
                            },
                        };
                    }
                }
                catch (error) {
                    const errorMessage = error instanceof Error ? error.message : error;
                    this.connection.console.warn(`getExplainshellDocumentation exception: ${errorMessage}`);
                }
            }
            if (ReservedWords.isReservedWord(word) ||
                Builtins.isBuiltin(word) ||
                this.executables.isExecutableOnPATH(word)) {
                const shellDocumentation = yield (0, sh_1.getShellDocumentation)({ word });
                if (shellDocumentation) {
                    // eslint-disable-next-line no-console
                    return { contents: getMarkdownContent(shellDocumentation) };
                }
            }
            else {
                const symbolDocumentation = deduplicateSymbols({
                    symbols: this.analyzer.findSymbolsMatchingWord({
                        exactMatch: true,
                        word,
                    }),
                    currentUri,
                })
                    // do not return hover referencing for the current line
                    .filter((symbol) => symbol.location.range.start.line !== params.position.line)
                    .map((symbol) => this.getDocumentationForSymbol({ currentUri, symbol }));
                if (symbolDocumentation.length === 1) {
                    return { contents: symbolDocumentation[0] };
                }
            }
            return null;
        });
    }
    onDefinition(params) {
        const word = this.getWordAtPoint(params);
        this.logRequest({ request: 'onDefinition', params, word });
        if (!word) {
            return null;
        }
        return this.analyzer.findDefinition({ word });
    }
    onDocumentSymbol(params) {
        this.connection.console.log(`onDocumentSymbol`);
        return this.analyzer.findSymbolsForFile({ uri: params.textDocument.uri });
    }
    onWorkspaceSymbol(params) {
        this.connection.console.log('onWorkspaceSymbol');
        return this.analyzer.search(params.query);
    }
    onDocumentHighlight(params) {
        const word = this.getWordAtPoint(params);
        this.logRequest({ request: 'onDocumentHighlight', params, word });
        if (!word) {
            return [];
        }
        return this.analyzer
            .findOccurrences(params.textDocument.uri, word)
            .map((n) => ({ range: n.range }));
    }
    onReferences(params) {
        const word = this.getWordAtPoint(params);
        this.logRequest({ request: 'onReferences', params, word });
        if (!word) {
            return null;
        }
        return this.analyzer.findReferences(word);
    }
    onCompletion(params) {
        const word = this.getWordAtPoint(Object.assign(Object.assign({}, params), { position: {
                line: params.position.line,
                // Go one character back to get completion on the current word
                character: Math.max(params.position.character - 1, 0),
            } }));
        this.logRequest({ request: 'onCompletion', params, word });
        if (word && word.startsWith('#')) {
            // Inside a comment block
            return [];
        }
        if (word && word === '{') {
            // We should not complete when it is not prefixed by a $.
            // This case needs to be here
            // because { is a completionProvider triggerCharacter.
            return [];
        }
        let options = [];
        if (word && word.startsWith('-')) {
            const commandName = this.getCommandNameAtPoint(Object.assign(Object.assign({}, params), { position: {
                    line: params.position.line,
                    // Go one character back to get completion on the current word
                    character: Math.max(params.position.character - 1, 0),
                } }));
            if (commandName) {
                options = getCommandOptions(commandName, word);
            }
        }
        const currentUri = params.textDocument.uri;
        // TODO: an improvement here would be to detect if the current word is
        // not only a parameter expansion prefix, but also if the word is actually
        // inside a parameter expansion (e.g. auto completing on a word $MY_VARIA).
        const shouldCompleteOnVariables = word
            ? PARAMETER_EXPANSION_PREFIXES.has(word)
            : false;
        const symbolCompletions = word === null
            ? []
            : this.getCompletionItemsForSymbols({
                symbols: shouldCompleteOnVariables
                    ? this.analyzer.getAllVariableSymbols()
                    : this.analyzer.findSymbolsMatchingWord({
                        exactMatch: false,
                        word,
                    }),
                currentUri,
            });
        if (shouldCompleteOnVariables) {
            // In case we auto complete on a word that starts a parameter expansion,
            // we do not return anything else than variable/parameter suggestions.
            // Note: that LSP clients should not call onCompletion in the middle
            // of a word, so the following should work for client.
            return symbolCompletions;
        }
        const reservedWordsCompletions = ReservedWords.LIST.map((reservedWord) => ({
            label: reservedWord,
            kind: LSP.CompletionItemKind.Keyword,
            data: {
                name: reservedWord,
                type: types_1.CompletionItemDataType.ReservedWord,
            },
        }));
        const programCompletions = this.executables
            .list()
            .filter((executable) => !Builtins.isBuiltin(executable))
            .map((executable) => {
            return {
                label: executable,
                kind: LSP.CompletionItemKind.Function,
                data: {
                    name: executable,
                    type: types_1.CompletionItemDataType.Executable,
                },
            };
        });
        const builtinsCompletions = Builtins.LIST.map((builtin) => ({
            label: builtin,
            kind: LSP.CompletionItemKind.Function,
            data: {
                name: builtin,
                type: types_1.CompletionItemDataType.Builtin,
            },
        }));
        const optionsCompletions = options.map((option) => ({
            label: option,
            kind: LSP.CompletionItemKind.Constant,
            data: {
                name: option,
                type: types_1.CompletionItemDataType.Symbol,
            },
        }));
        const allCompletions = [
            ...reservedWordsCompletions,
            ...symbolCompletions,
            ...programCompletions,
            ...builtinsCompletions,
            ...optionsCompletions,
        ];
        if (word) {
            // Filter to only return suffixes of the current word
            return allCompletions.filter((item) => item.label.startsWith(word));
        }
        return allCompletions;
    }
    onCompletionResolve(item) {
        return __awaiter(this, void 0, void 0, function* () {
            const { data: { name, type }, } = item;
            this.connection.console.log(`onCompletionResolve name=${name} type=${type}`);
            try {
                let documentation = null;
                if (type === types_1.CompletionItemDataType.Executable ||
                    type === types_1.CompletionItemDataType.Builtin ||
                    type === types_1.CompletionItemDataType.ReservedWord) {
                    documentation = yield (0, sh_1.getShellDocumentation)({ word: name });
                }
                return documentation
                    ? Object.assign(Object.assign({}, item), { documentation: getMarkdownContent(documentation) }) : item;
            }
            catch (error) {
                return item;
            }
        });
    }
}
exports.default = BashServer;
/**
 * Deduplicate symbols by prioritizing the current file.
 */
function deduplicateSymbols({ symbols, currentUri, }) {
    const isCurrentFile = ({ location: { uri } }) => uri === currentUri;
    const getSymbolId = ({ name, kind }) => `${name}${kind}`;
    const symbolsCurrentFile = symbols.filter((s) => isCurrentFile(s));
    const symbolsOtherFiles = symbols
        .filter((s) => !isCurrentFile(s))
        // Remove identical symbols matching current file
        .filter((symbolOtherFiles) => !symbolsCurrentFile.some((symbolCurrentFile) => getSymbolId(symbolCurrentFile) === getSymbolId(symbolOtherFiles)));
    return (0, array_1.uniqueBasedOnHash)([...symbolsCurrentFile, ...symbolsOtherFiles], getSymbolId);
}
function symbolKindToCompletionKind(s) {
    switch (s) {
        case LSP.SymbolKind.File:
            return LSP.CompletionItemKind.File;
        case LSP.SymbolKind.Module:
        case LSP.SymbolKind.Namespace:
        case LSP.SymbolKind.Package:
            return LSP.CompletionItemKind.Module;
        case LSP.SymbolKind.Class:
            return LSP.CompletionItemKind.Class;
        case LSP.SymbolKind.Method:
            return LSP.CompletionItemKind.Method;
        case LSP.SymbolKind.Property:
            return LSP.CompletionItemKind.Property;
        case LSP.SymbolKind.Field:
            return LSP.CompletionItemKind.Field;
        case LSP.SymbolKind.Constructor:
            return LSP.CompletionItemKind.Constructor;
        case LSP.SymbolKind.Enum:
            return LSP.CompletionItemKind.Enum;
        case LSP.SymbolKind.Interface:
            return LSP.CompletionItemKind.Interface;
        case LSP.SymbolKind.Function:
            return LSP.CompletionItemKind.Function;
        case LSP.SymbolKind.Variable:
            return LSP.CompletionItemKind.Variable;
        case LSP.SymbolKind.Constant:
            return LSP.CompletionItemKind.Constant;
        case LSP.SymbolKind.String:
        case LSP.SymbolKind.Number:
        case LSP.SymbolKind.Boolean:
        case LSP.SymbolKind.Array:
        case LSP.SymbolKind.Key:
        case LSP.SymbolKind.Null:
            return LSP.CompletionItemKind.Text;
        case LSP.SymbolKind.Object:
            return LSP.CompletionItemKind.Module;
        case LSP.SymbolKind.EnumMember:
            return LSP.CompletionItemKind.EnumMember;
        case LSP.SymbolKind.Struct:
            return LSP.CompletionItemKind.Struct;
        case LSP.SymbolKind.Event:
            return LSP.CompletionItemKind.Event;
        case LSP.SymbolKind.Operator:
            return LSP.CompletionItemKind.Operator;
        case LSP.SymbolKind.TypeParameter:
            return LSP.CompletionItemKind.TypeParameter;
        default:
            return LSP.CompletionItemKind.Text;
    }
}
function symbolKindToDescription(s) {
    switch (s) {
        case LSP.SymbolKind.Function:
            return 'Function';
        case LSP.SymbolKind.Variable:
            return 'Variable';
        default:
            return 'Keyword';
    }
}
const getMarkdownContent = (documentation) => ({
    value: ['``` man', documentation, '```'].join('\n'),
    // Passed as markdown for syntax highlighting
    kind: 'markdown',
});
function getCommandOptions(name, word) {
    // TODO: The options could be cached.
    const options = Process.spawnSync(Path.join(__dirname, '../src/get-options.sh'), [
        name,
        word,
    ]);
    if (options.status !== 0) {
        return [];
    }
    return options.stdout
        .toString()
        .split('\t')
        .map((l) => l.trim())
        .filter((l) => l.length > 0);
}
//# sourceMappingURL=server.js.map