"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 node_fetch_1 = require("node-fetch");
const URI = require("urijs");
const url = require("url");
const util_1 = require("util");
const LSP = require("vscode-languageserver/node");
const vscode_languageserver_textdocument_1 = require("vscode-languageserver-textdocument");
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 = (0, 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({ console, 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.
            environment_variable_assignment: LSP.SymbolKind.Variable,
            function_definition: LSP.SymbolKind.Function,
            variable_assignment: LSP.SymbolKind.Variable,
        };
        this.parser = parser;
        this.console = console;
    }
    /**
     * Initiates a background analysis of the files in the given rootPath to
     * enable features across files.
     */
    initiateBackgroundAnalysis({ backgroundAnalysisMaxFiles, globPattern, rootPath, }) {
        return __awaiter(this, void 0, void 0, function* () {
            if (backgroundAnalysisMaxFiles <= 0) {
                this.console.log(`BackgroundAnalysis: skipping as backgroundAnalysisMaxFiles was 0...`);
                return { filesParsed: 0 };
            }
            this.console.log(`BackgroundAnalysis: resolving glob "${globPattern}" inside "${rootPath}"...`);
            const lookupStartTime = Date.now();
            const getTimePassed = () => `${(Date.now() - lookupStartTime) / 1000} seconds`;
            let filePaths = [];
            try {
                filePaths = yield (0, fs_1.getFilePaths)({
                    globPattern,
                    rootPath,
                    maxItems: backgroundAnalysisMaxFiles,
                });
            }
            catch (error) {
                const errorMessage = error instanceof Error ? error.message : error;
                this.console.warn(`BackgroundAnalysis: failed resolved glob "${globPattern}". The experience across files will be degraded. Error: ${errorMessage}`);
                return { filesParsed: 0 };
            }
            this.console.log(`BackgroundAnalysis: Glob resolved with ${filePaths.length} files after ${getTimePassed()}`);
            for (const filePath of filePaths) {
                const uri = url.pathToFileURL(filePath).href;
                try {
                    const fileContent = yield readFileAsync(filePath, 'utf8');
                    const { shebang, shellDialect } = (0, shebang_1.analyzeShebang)(fileContent);
                    if (shebang && !shellDialect) {
                        this.console.log(`BackgroundAnalysis: Skipping file ${uri} with shebang "${shebang}"`);
                        continue;
                    }
                    this.analyze(uri, vscode_languageserver_textdocument_1.TextDocument.create(uri, 'shell', 1, fileContent));
                }
                catch (error) {
                    const errorMessage = error instanceof Error ? error.message : error;
                    this.console.warn(`BackgroundAnalysis: Failed analyzing ${uri}. Error: ${errorMessage}`);
                }
            }
            this.console.log(`BackgroundAnalysis: Completed after ${getTimePassed()}.`);
            return {
                filesParsed: filePaths.length,
            };
        });
    }
    /**
     * Find all the locations where something has been defined.
     */
    findDefinition({ word }) {
        const symbols = [];
        Object.keys(this.uriToDeclarations).forEach((uri) => {
            const declarationNames = this.uriToDeclarations[uri][word] || [];
            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 {};
            }
            const cmd = this.uriToFileContent[params.textDocument.uri].slice(interestingNode.startIndex, interestingNode.endIndex);
            const url = URI(endpoint).path('/api/explain').addQuery('cmd', cmd).toString();
            const explainshellRawResponse = yield (0, node_fetch_1.default)(url);
            const explainshellResponse = (yield explainshellRawResponse.json());
            if (!explainshellRawResponse.ok) {
                throw new Error(`HTTP request failed: ${url}`);
            }
            else if (!explainshellResponse.matches) {
                return {};
            }
            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);
                return { helpHTML: match && match.helpHTML };
            }
        });
    }
    /**
     * Find all the locations where something named name has been defined.
     */
    findReferences(name) {
        const uris = Object.keys(this.uriToTreeSitterTrees);
        return (0, 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 (0, flatten_1.flattenObjectValues)(declarationsInFile);
    }
    /**
     * Find symbol completions for the given word.
     *
     * TODO: if a file is not included we probably shouldn't include it declarations from it.
     */
    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 word = contents.slice(named.startIndex, named.endIndex);
                const namedDeclarations = this.uriToDeclarations[uri][word] || [];
                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(word, this.treeSitterTypeToLSPKind[n.type], TreeSitterUtil.range(n), uri, parentName));
                this.uriToDeclarations[uri][word] = 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 node at the given point.
     */
    nodeAtPoint(uri, line, column) {
        const document = this.uriToTreeSitterTrees[uri];
        if (!(document === null || document === void 0 ? void 0 : document.rootNode)) {
            // Check for lacking rootNode (due to failed parse?)
            return null;
        }
        return document.rootNode.descendantForPosition({ row: line, column });
    }
    /**
     * Find the full word at the given point.
     */
    wordAtPoint(uri, line, column) {
        const node = this.nodeAtPoint(uri, line, column);
        if (!node || node.childCount > 0 || node.text.trim() === '') {
            return null;
        }
        return node.text.trim();
    }
    /**
     * Find the name of the command at the given point.
     */
    commandNameAtPoint(uri, line, column) {
        let node = this.nodeAtPoint(uri, line, column);
        while (node && node.type !== 'command') {
            node = node.parent;
        }
        if (!node) {
            return null;
        }
        const firstChild = node.firstNamedChild;
        if (!firstChild || firstChild.type !== 'command_name') {
            return null;
        }
        return firstChild.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].trimRight() : 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)) !== null) {
            commentBlock.push(currentComment);
            commentBlockIndex -= 1;
            currentLine = doc.getText({
                start: { line: commentBlockIndex, character: 0 },
                end: { line: commentBlockIndex + 1, character: 0 },
            });
        }
        if (commentBlock.length) {
            commentBlock.push('```txt');
            // since we searched from bottom up, we then reverse
            // the lines so that it reads top down.
            commentBlock.reverse();
            commentBlock.push('```');
            return commentBlock.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