"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 fs = require("fs");
const FuzzySearch = require("fuzzy-search");
const request = require("request-promise-native");
const URI = require("urijs");
const util_1 = require("util");
const LSP = require("vscode-languageserver");
const config_1 = require("./config");
const flatten_1 = require("./util/flatten");
const fs_1 = require("./util/fs");
const shebang_1 = require("./util/shebang");
const TreeSitterUtil = require("./util/tree-sitter");
const readFileAsync = util_1.promisify(fs.readFile);
/**
 * The Analyzer uses the Abstract Syntax Trees (ASTs) that are provided by
 * tree-sitter to find definitions, reference, etc.
 */
class Analyzer {
    constructor(parser) {
        this.uriToTextDocument = {};
        this.uriToTreeSitterTrees = {};
        // We need this to find the word at a given point etc.
        this.uriToFileContent = {};
        this.uriToDeclarations = {};
        this.treeSitterTypeToLSPKind = {
            // These keys are using underscores as that's the naming convention in tree-sitter.
            /* eslint-disable @typescript-eslint/camelcase */
            environment_variable_assignment: LSP.SymbolKind.Variable,
            function_definition: LSP.SymbolKind.Function,
            variable_assignment: LSP.SymbolKind.Variable,
        };
        this.parser = parser;
    }
    /**
     * Initialize the Analyzer based on a connection to the client and an optional
     * root path.
     *
     * If the rootPath is provided it will initialize all shell files it can find
     * anywhere on that path. This non-exhaustive glob is used to preload the parser.
     */
    static fromRoot({ connection, rootPath, parser, }) {
        return __awaiter(this, void 0, void 0, function* () {
            const analyzer = new Analyzer(parser);
            if (rootPath) {
                const globPattern = config_1.getGlobPattern();
                connection.console.log(`Analyzing files matching glob "${globPattern}" inside ${rootPath}`);
                const lookupStartTime = Date.now();
                const getTimePassed = () => `${(Date.now() - lookupStartTime) / 1000} seconds`;
                let filePaths = [];
                try {
                    filePaths = yield fs_1.getFilePaths({ globPattern, rootPath });
                }
                catch (error) {
                    connection.window.showWarningMessage(`Failed to analyze bash files using the glob "${globPattern}". The experience will be degraded. Error: ${error.message}`);
                }
                // TODO: we could load all files without extensions: globPattern: '**/[^.]'
                connection.console.log(`Glob resolved with ${filePaths.length} files after ${getTimePassed()}`);
                for (const filePath of filePaths) {
                    const uri = `file://${filePath}`;
                    connection.console.log(`Analyzing ${uri}`);
                    try {
                        const fileContent = yield readFileAsync(filePath, 'utf8');
                        const shebang = shebang_1.getShebang(fileContent);
                        if (shebang && !shebang_1.isBashShebang(shebang)) {
                            connection.console.log(`Skipping file ${uri} with shebang "${shebang}"`);
                            continue;
                        }
                        analyzer.analyze(uri, LSP.TextDocument.create(uri, 'shell', 1, fileContent));
                    }
                    catch (error) {
                        connection.console.warn(`Failed analyzing ${uri}. Error: ${error.message}`);
                    }
                }
                connection.console.log(`Analyzer finished after ${getTimePassed()}`);
            }
            return analyzer;
        });
    }
    /**
     * Find all the locations where something named name has been defined.
     */
    findDefinition(name) {
        const symbols = [];
        Object.keys(this.uriToDeclarations).forEach(uri => {
            const declarationNames = this.uriToDeclarations[uri][name] || [];
            declarationNames.forEach(d => symbols.push(d));
        });
        return symbols.map(s => s.location);
    }
    /**
     * Find all the symbols matching the query using fuzzy search.
     */
    search(query) {
        const searcher = new FuzzySearch(this.getAllSymbols(), ['name'], {
            caseSensitive: true,
        });
        return searcher.search(query);
    }
    getExplainshellDocumentation({ params, endpoint, }) {
        return __awaiter(this, void 0, void 0, function* () {
            const leafNode = this.uriToTreeSitterTrees[params.textDocument.uri].rootNode.descendantForPosition({
                row: params.position.line,
                column: params.position.character,
            });
            // explainshell needs the whole command, not just the "word" (tree-sitter
            // parlance) that the user hovered over. A relatively successful heuristic
            // is to simply go up one level in the AST. If you go up too far, you'll
            // start to include newlines, and explainshell completely balks when it
            // encounters newlines.
            const interestingNode = leafNode.type === 'word' ? leafNode.parent : leafNode;
            if (!interestingNode) {
                return {
                    status: 'error',
                    message: 'no interestingNode found',
                };
            }
            const cmd = this.uriToFileContent[params.textDocument.uri].slice(interestingNode.startIndex, interestingNode.endIndex);
            // FIXME: type the response and unit test it
            const explainshellResponse = yield request({
                uri: URI(endpoint)
                    .path('/api/explain')
                    .addQuery('cmd', cmd)
                    .toString(),
                json: true,
            });
            // Attaches debugging information to the return value (useful for logging to
            // VS Code output).
            const response = Object.assign(Object.assign({}, explainshellResponse), { cmd, cmdType: interestingNode.type });
            if (explainshellResponse.status === 'error') {
                return response;
            }
            else if (!explainshellResponse.matches) {
                return Object.assign(Object.assign({}, response), { status: 'error' });
            }
            else {
                const offsetOfMousePointerInCommand = this.uriToTextDocument[params.textDocument.uri].offsetAt(params.position) -
                    interestingNode.startIndex;
                const match = explainshellResponse.matches.find((helpItem) => helpItem.start <= offsetOfMousePointerInCommand &&
                    offsetOfMousePointerInCommand < helpItem.end);
                const helpHTML = match && match.helpHTML;
                if (!helpHTML) {
                    return Object.assign(Object.assign({}, response), { status: 'error' });
                }
                return Object.assign(Object.assign({}, response), { helpHTML });
            }
        });
    }
    /**
     * Find all the locations where something named name has been defined.
     */
    findReferences(name) {
        const uris = Object.keys(this.uriToTreeSitterTrees);
        return flatten_1.flattenArray(uris.map(uri => this.findOccurrences(uri, name)));
    }
    /**
     * Find all occurrences of name in the given file.
     * It's currently not scope-aware.
     */
    findOccurrences(uri, query) {
        const tree = this.uriToTreeSitterTrees[uri];
        const contents = this.uriToFileContent[uri];
        const locations = [];
        TreeSitterUtil.forEach(tree.rootNode, n => {
            let name = null;
            let range = null;
            if (TreeSitterUtil.isReference(n)) {
                const node = n.firstNamedChild || n;
                name = contents.slice(node.startIndex, node.endIndex);
                range = TreeSitterUtil.range(node);
            }
            else if (TreeSitterUtil.isDefinition(n)) {
                const namedNode = n.firstNamedChild;
                if (namedNode) {
                    name = contents.slice(namedNode.startIndex, namedNode.endIndex);
                    range = TreeSitterUtil.range(namedNode);
                }
            }
            if (name === query && range !== null) {
                locations.push(LSP.Location.create(uri, range));
            }
        });
        return locations;
    }
    /**
     * Find all symbol definitions in the given file.
     */
    findSymbolsForFile({ uri }) {
        const declarationsInFile = this.uriToDeclarations[uri] || {};
        return flatten_1.flattenObjectValues(declarationsInFile);
    }
    /**
     * Find symbol completions for the given word.
     */
    findSymbolsMatchingWord({ exactMatch, word, }) {
        const symbols = [];
        Object.keys(this.uriToDeclarations).forEach(uri => {
            const declarationsInFile = this.uriToDeclarations[uri] || {};
            Object.keys(declarationsInFile).map(name => {
                const match = exactMatch ? name === word : name.startsWith(word);
                if (match) {
                    declarationsInFile[name].forEach(symbol => symbols.push(symbol));
                }
            });
        });
        return symbols;
    }
    /**
     * Analyze the given document, cache the tree-sitter AST, and iterate over the
     * tree to find declarations.
     *
     * Returns all, if any, syntax errors that occurred while parsing the file.
     *
     */
    analyze(uri, document) {
        const contents = document.getText();
        const tree = this.parser.parse(contents);
        this.uriToTextDocument[uri] = document;
        this.uriToTreeSitterTrees[uri] = tree;
        this.uriToDeclarations[uri] = {};
        this.uriToFileContent[uri] = contents;
        const problems = [];
        TreeSitterUtil.forEach(tree.rootNode, (n) => {
            if (n.type === 'ERROR') {
                problems.push(LSP.Diagnostic.create(TreeSitterUtil.range(n), 'Failed to parse expression', LSP.DiagnosticSeverity.Error));
                return;
            }
            else if (TreeSitterUtil.isDefinition(n)) {
                const named = n.firstNamedChild;
                if (named === null) {
                    return;
                }
                const name = contents.slice(named.startIndex, named.endIndex);
                const namedDeclarations = this.uriToDeclarations[uri][name] || [];
                const parent = TreeSitterUtil.findParent(n, p => p.type === 'function_definition');
                const parentName = parent && parent.firstNamedChild
                    ? contents.slice(parent.firstNamedChild.startIndex, parent.firstNamedChild.endIndex)
                    : ''; // TODO: unsure what we should do here?
                namedDeclarations.push(LSP.SymbolInformation.create(name, this.treeSitterTypeToLSPKind[n.type], TreeSitterUtil.range(n), uri, parentName));
                this.uriToDeclarations[uri][name] = namedDeclarations;
            }
        });
        function findMissingNodes(node) {
            if (node.isMissing()) {
                problems.push(LSP.Diagnostic.create(TreeSitterUtil.range(node), `Syntax error: expected "${node.type}" somewhere in the file`, LSP.DiagnosticSeverity.Warning));
            }
            else if (node.hasError()) {
                node.children.forEach(findMissingNodes);
            }
        }
        findMissingNodes(tree.rootNode);
        return problems;
    }
    /**
     * Find the full word at the given point.
     */
    wordAtPoint(uri, line, column) {
        const document = this.uriToTreeSitterTrees[uri];
        if (!document.rootNode) {
            // Check for lacking rootNode (due to failed parse?)
            return null;
        }
        const node = document.rootNode.descendantForPosition({ row: line, column });
        if (!node || node.childCount > 0 || node.text.trim() === '') {
            return null;
        }
        return node.text.trim();
    }
    /**
     * Find a block of comments above a line position
     */
    commentsAbove(uri, line) {
        const doc = this.uriToTextDocument[uri];
        const commentBlock = [];
        // start from the line above
        let commentBlockIndex = line - 1;
        // will return the comment string without the comment '#'
        // and without leading whitespace, or null if the line 'l'
        // is not a comment line
        const getComment = (l) => {
            // this regexp has to be defined within the function
            const commentRegExp = /^\s*#\s*(.*)/g;
            const matches = commentRegExp.exec(l);
            return matches ? matches[1].trim() : null;
        };
        let currentLine = doc.getText({
            start: { line: commentBlockIndex, character: 0 },
            end: { line: commentBlockIndex + 1, character: 0 },
        });
        // iterate on every line above and including
        // the current line until getComment returns null
        let currentComment = '';
        while ((currentComment = getComment(currentLine))) {
            commentBlock.push(currentComment);
            commentBlockIndex -= 1;
            currentLine = doc.getText({
                start: { line: commentBlockIndex, character: 0 },
                end: { line: commentBlockIndex + 1, character: 0 },
            });
        }
        if (commentBlock.length) {
            // since we searched from bottom up, we then reverse
            // the lines so that it reads top down.
            return commentBlock.reverse().join('\n');
        }
        // no comments found above line:
        return null;
    }
    getAllVariableSymbols() {
        return this.getAllSymbols().filter(symbol => symbol.kind === LSP.SymbolKind.Variable);
    }
    getAllSymbols() {
        // NOTE: this could be cached, it takes < 1 ms to generate for a project with 250 bash files...
        const symbols = [];
        Object.keys(this.uriToDeclarations).forEach(uri => {
            Object.keys(this.uriToDeclarations[uri]).forEach(name => {
                const declarationNames = this.uriToDeclarations[uri][name] || [];
                declarationNames.forEach(d => symbols.push(d));
            });
        });
        return symbols;
    }
}
exports.default = Analyzer;
//# sourceMappingURL=analyser.js.map