"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 url = require("url");
const util_1 = require("util");
const LSP = require("vscode-languageserver/node");
const vscode_languageserver_textdocument_1 = require("vscode-languageserver-textdocument");
const array_1 = require("./util/array");
const declarations_1 = require("./util/declarations");
const fs_1 = require("./util/fs");
const logger_1 = require("./util/logger");
const lsp_1 = require("./util/lsp");
const shebang_1 = require("./util/shebang");
const sourcing = require("./util/sourcing");
const TreeSitterUtil = require("./util/tree-sitter");
/**
 * The Analyzer uses the Abstract Syntax Trees (ASTs) that are provided by
 * tree-sitter to find definitions, reference, etc.
 */
class Analyzer {
    constructor({ enableSourceErrorDiagnostics = false, includeAllWorkspaceSymbols = false, parser, workspaceFolder, }) {
        this.uriToAnalyzedDocument = {};
        this.enableSourceErrorDiagnostics = enableSourceErrorDiagnostics;
        this.includeAllWorkspaceSymbols = includeAllWorkspaceSymbols;
        this.parser = parser;
        this.workspaceFolder = workspaceFolder;
    }
    /**
     * Analyze the given document, cache the tree-sitter AST, and iterate over the
     * tree to find declarations.
     */
    analyze({ document, uri, // NOTE: we don't use document.uri to make testing easier
     }) {
        const diagnostics = [];
        const fileContent = document.getText();
        const tree = this.parser.parse(fileContent);
        const globalDeclarations = (0, declarations_1.getGlobalDeclarations)({ tree, uri });
        const sourceCommands = sourcing.getSourceCommands({
            fileUri: uri,
            rootPath: this.workspaceFolder,
            tree,
        });
        const sourcedUris = new Set(sourceCommands
            .map((sourceCommand) => sourceCommand.uri)
            .filter((uri) => uri !== null));
        this.uriToAnalyzedDocument[uri] = {
            document,
            globalDeclarations,
            sourcedUris,
            sourceCommands: sourceCommands.filter((sourceCommand) => !sourceCommand.error),
            tree,
        };
        if (!this.includeAllWorkspaceSymbols) {
            sourceCommands
                .filter((sourceCommand) => sourceCommand.error)
                .forEach((sourceCommand) => {
                logger_1.logger.warn(`${uri} line ${sourceCommand.range.start.line}: ${sourceCommand.error}`);
                if (this.enableSourceErrorDiagnostics) {
                    diagnostics.push(LSP.Diagnostic.create(sourceCommand.range, [
                        `Source command could not be analyzed: ${sourceCommand.error}.\n`,
                        'Consider adding a ShellCheck directive above this line to fix or ignore this:',
                        '# shellcheck source=/my-file.sh # specify the file to source',
                        '# shellcheck source-path=my_script_folder # specify the folder to search in',
                        '# shellcheck source=/dev/null # to ignore the error',
                        '',
                        'Disable this message by changing the configuration option "enableSourceErrorDiagnostics"',
                    ].join('\n'), LSP.DiagnosticSeverity.Information, undefined, 'bash-language-server'));
                }
            });
        }
        if (tree.rootNode.hasError()) {
            logger_1.logger.warn(`Error while parsing ${uri}: syntax error`);
        }
        return diagnostics;
    }
    /**
     * Initiates a background analysis of the files in the workspaceFolder to
     * enable features across files.
     *
     * NOTE that when the source aware feature is enabled files are also parsed
     * when they are found.
     */
    initiateBackgroundAnalysis({ backgroundAnalysisMaxFiles, globPattern, }) {
        return __awaiter(this, void 0, void 0, function* () {
            const rootPath = this.workspaceFolder;
            if (!rootPath) {
                return { filesParsed: 0 };
            }
            if (backgroundAnalysisMaxFiles <= 0) {
                logger_1.logger.info(`BackgroundAnalysis: skipping as backgroundAnalysisMaxFiles was 0...`);
                return { filesParsed: 0 };
            }
            logger_1.logger.info(`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;
                logger_1.logger.warn(`BackgroundAnalysis: failed resolved glob "${globPattern}". The experience across files will be degraded. Error: ${errorMessage}`);
                return { filesParsed: 0 };
            }
            logger_1.logger.info(`BackgroundAnalysis: Glob resolved with ${filePaths.length} files after ${getTimePassed()}`);
            for (const filePath of filePaths) {
                const uri = url.pathToFileURL(filePath).href;
                try {
                    const fileContent = yield fs.promises.readFile(filePath, 'utf8');
                    const { shebang, shellDialect } = (0, shebang_1.analyzeShebang)(fileContent);
                    if (shebang && !shellDialect) {
                        logger_1.logger.info(`BackgroundAnalysis: Skipping file ${uri} with shebang "${shebang}"`);
                        continue;
                    }
                    this.analyze({
                        document: vscode_languageserver_textdocument_1.TextDocument.create(uri, 'shell', 1, fileContent),
                        uri,
                    });
                }
                catch (error) {
                    const errorMessage = error instanceof Error ? error.message : error;
                    logger_1.logger.warn(`BackgroundAnalysis: Failed analyzing ${uri}. Error: ${errorMessage}`);
                }
            }
            logger_1.logger.info(`BackgroundAnalysis: Completed after ${getTimePassed()}.`);
            return {
                filesParsed: filePaths.length,
            };
        });
    }
    /**
     * Find all the locations where the word was declared.
     */
    findDeclarationLocations({ position, uri, word, }) {
        var _a;
        // If the word is sourced, return the location of the source file
        const sourcedUri = (_a = this.uriToAnalyzedDocument[uri]) === null || _a === void 0 ? void 0 : _a.sourceCommands.filter((sourceCommand) => (0, lsp_1.isPositionIncludedInRange)(position, sourceCommand.range)).map((sourceCommand) => sourceCommand.uri)[0];
        if (sourcedUri) {
            return [LSP.Location.create(sourcedUri, LSP.Range.create(0, 0, 0, 0))];
        }
        return this.findDeclarationsMatchingWord({
            exactMatch: true,
            position,
            uri,
            word,
        }).map((symbol) => symbol.location);
    }
    /**
     * Find all the declaration symbols in the workspace matching the query using fuzzy search.
     */
    findDeclarationsWithFuzzySearch(query) {
        const searcher = new FuzzySearch(this.getAllDeclarations(), ['name'], {
            caseSensitive: true,
        });
        return searcher.search(query);
    }
    /**
     * Find declarations for the given word and position.
     */
    findDeclarationsMatchingWord({ exactMatch, position, uri, word, }) {
        return this.getAllDeclarations({ uri, position }).filter((symbol) => {
            if (exactMatch) {
                return symbol.name === word;
            }
            else {
                return symbol.name.startsWith(word);
            }
        });
    }
    /**
     * Find all the locations where the given word was defined or referenced.
     * This will include commands, functions, variables, etc.
     *
     * It's currently not scope-aware, see findOccurrences.
     */
    findReferences(word) {
        const uris = Object.keys(this.uriToAnalyzedDocument);
        return (0, array_1.flattenArray)(uris.map((uri) => this.findOccurrences(uri, word)));
    }
    /**
     * Find all occurrences of a word in the given file.
     * It's currently not scope-aware.
     *
     * This will include commands, functions, variables, etc.
     *
     * It's currently not scope-aware, meaning references does include
     * references to functions and variables that has the same name but
     * are defined in different files.
     */
    findOccurrences(uri, word) {
        const analyzedDocument = this.uriToAnalyzedDocument[uri];
        if (!analyzedDocument) {
            return [];
        }
        const { tree } = analyzedDocument;
        const locations = [];
        TreeSitterUtil.forEach(tree.rootNode, (n) => {
            let namedNode = null;
            if (TreeSitterUtil.isReference(n)) {
                // NOTE: a reference can be a command, variable, function, etc.
                namedNode = n.firstNamedChild || n;
            }
            else if (TreeSitterUtil.isDefinition(n)) {
                namedNode = n.firstNamedChild;
            }
            if (namedNode && namedNode.text === word) {
                const range = TreeSitterUtil.range(namedNode);
                const alreadyInLocations = locations.some((loc) => {
                    return (0, util_1.isDeepStrictEqual)(loc.range, range);
                });
                if (!alreadyInLocations) {
                    locations.push(LSP.Location.create(uri, range));
                }
            }
        });
        return locations;
    }
    getAllVariables({ position, uri, }) {
        return this.getAllDeclarations({ uri, position }).filter((symbol) => symbol.kind === LSP.SymbolKind.Variable);
    }
    /**
     * Get all symbol declarations in the given file. This is used for generating an outline.
     *
     * TODO: convert to DocumentSymbol[] which is a hierarchy of symbols found in a given text document.
     */
    getDeclarationsForUri({ uri }) {
        var _a;
        const tree = (_a = this.uriToAnalyzedDocument[uri]) === null || _a === void 0 ? void 0 : _a.tree;
        if (!(tree === null || tree === void 0 ? void 0 : tree.rootNode)) {
            return [];
        }
        return (0, declarations_1.getAllDeclarationsInTree)({ uri, tree });
    }
    /**
     * Get the document for the given URI.
     */
    getDocument(uri) {
        var _a;
        return (_a = this.uriToAnalyzedDocument[uri]) === null || _a === void 0 ? void 0 : _a.document;
    }
    // TODO: move somewhere else than the analyzer...
    getExplainshellDocumentation({ params, endpoint, }) {
        return __awaiter(this, void 0, void 0, function* () {
            const analyzedDocument = this.uriToAnalyzedDocument[params.textDocument.uri];
            const leafNode = analyzedDocument === null || analyzedDocument === void 0 ? void 0 : analyzedDocument.tree.rootNode.descendantForPosition({
                row: params.position.line,
                column: params.position.character,
            });
            if (!leafNode || !analyzedDocument) {
                return {};
            }
            // 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 searchParams = new URLSearchParams({ cmd: interestingNode.text }).toString();
            const url = `${endpoint}/api/explain?${searchParams}`;
            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 = analyzedDocument.document.offsetAt(params.position) - interestingNode.startIndex;
                const match = explainshellResponse.matches.find((helpItem) => helpItem.start <= offsetOfMousePointerInCommand &&
                    offsetOfMousePointerInCommand < helpItem.end);
                return { helpHTML: match && match.helpHTML };
            }
        });
    }
    /**
     * 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) {
        var _a;
        const doc = (_a = this.uriToAnalyzedDocument[uri]) === null || _a === void 0 ? void 0 : _a.document;
        if (!doc) {
            return null;
        }
        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;
    }
    /**
     * 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();
    }
    wordAtPointFromTextPosition(params) {
        return this.wordAtPoint(params.textDocument.uri, params.position.line, params.position.character);
    }
    setEnableSourceErrorDiagnostics(enableSourceErrorDiagnostics) {
        this.enableSourceErrorDiagnostics = enableSourceErrorDiagnostics;
    }
    setIncludeAllWorkspaceSymbols(includeAllWorkspaceSymbols) {
        this.includeAllWorkspaceSymbols = includeAllWorkspaceSymbols;
    }
    // Private methods
    /**
     * Returns all reachable URIs from the given URI based on sourced commands
     * If no URI is given, all URIs from the background analysis are returned.
     * If the includeAllWorkspaceSymbols flag is set, all URIs from the background analysis are also included.
     */
    getReachableUris({ fromUri } = {}) {
        if (!fromUri) {
            return Object.keys(this.uriToAnalyzedDocument);
        }
        const urisBasedOnSourcing = [
            fromUri,
            ...Array.from(this.findAllSourcedUris({ uri: fromUri })),
        ];
        if (this.includeAllWorkspaceSymbols) {
            return Array.from(new Set([...urisBasedOnSourcing, ...Object.keys(this.uriToAnalyzedDocument)]));
        }
        else {
            return urisBasedOnSourcing;
        }
    }
    getAnalyzedReachableUris({ fromUri } = {}) {
        return this.ensureUrisAreAnalyzed(this.getReachableUris({ fromUri }));
    }
    ensureUrisAreAnalyzed(uris) {
        return uris.filter((uri) => {
            if (!this.uriToAnalyzedDocument[uri]) {
                // Either the background analysis didn't run or the file is outside
                // the workspace. Let us try to analyze the file.
                try {
                    logger_1.logger.debug(`Analyzing file not covered by background analysis ${uri}`);
                    const fileContent = fs.readFileSync(new URL(uri), 'utf8');
                    this.analyze({
                        document: vscode_languageserver_textdocument_1.TextDocument.create(uri, 'shell', 1, fileContent),
                        uri,
                    });
                }
                catch (err) {
                    logger_1.logger.warn(`Error while analyzing file ${uri}: ${err}`);
                    return false;
                }
            }
            return true;
        });
    }
    /**
     * Get all declaration symbols (function or variables) from the given file/position
     * or from all files in the workspace. It will take into account the given position
     * to filter out irrelevant symbols.
     *
     * Note that this can return duplicates across the workspace.
     */
    getAllDeclarations({ uri: fromUri, position, } = {}) {
        return this.getAnalyzedReachableUris({ fromUri }).reduce((symbols, uri) => {
            var _a;
            const analyzedDocument = this.uriToAnalyzedDocument[uri];
            if (analyzedDocument) {
                if (uri !== fromUri || !position) {
                    // We use the global declarations for external files or if we do not have a position
                    const { globalDeclarations } = analyzedDocument;
                    Object.values(globalDeclarations).forEach((symbol) => symbols.push(symbol));
                }
                // For the current file we find declarations based on the current scope
                if (uri === fromUri && position) {
                    const node = (_a = analyzedDocument.tree.rootNode) === null || _a === void 0 ? void 0 : _a.descendantForPosition({
                        row: position.line,
                        column: position.character,
                    });
                    const localDeclarations = (0, declarations_1.getLocalDeclarations)({
                        node,
                        rootNode: analyzedDocument.tree.rootNode,
                        uri,
                    });
                    Object.keys(localDeclarations).map((name) => {
                        const symbolsMatchingWord = localDeclarations[name];
                        // Find the latest definition
                        let closestSymbol = null;
                        symbolsMatchingWord.forEach((symbol) => {
                            // Skip if the symbol is defined in the current file after the requested position
                            if (symbol.location.range.start.line > position.line) {
                                return;
                            }
                            if (closestSymbol === null ||
                                symbol.location.range.start.line > closestSymbol.location.range.start.line) {
                                closestSymbol = symbol;
                            }
                        });
                        if (closestSymbol) {
                            symbols.push(closestSymbol);
                        }
                    });
                }
            }
            return symbols;
        }, []);
    }
    findAllSourcedUris({ uri }) {
        const allSourcedUris = new Set([]);
        const addSourcedFilesFromUri = (fromUri) => {
            var _a;
            const sourcedUris = (_a = this.uriToAnalyzedDocument[fromUri]) === null || _a === void 0 ? void 0 : _a.sourcedUris;
            if (!sourcedUris) {
                return;
            }
            sourcedUris.forEach((sourcedUri) => {
                if (!allSourcedUris.has(sourcedUri)) {
                    allSourcedUris.add(sourcedUri);
                    addSourcedFilesFromUri(sourcedUri);
                }
            });
        };
        addSourcedFilesFromUri(uri);
        return allSourcedUris;
    }
    /**
     * Find the node at the given point.
     */
    nodeAtPoint(uri, line, column) {
        var _a;
        const tree = (_a = this.uriToAnalyzedDocument[uri]) === null || _a === void 0 ? void 0 : _a.tree;
        if (!(tree === null || tree === void 0 ? void 0 : tree.rootNode)) {
            // Check for lacking rootNode (due to failed parse?)
            return null;
        }
        return tree.rootNode.descendantForPosition({ row: line, column });
    }
}
exports.default = Analyzer;
//# sourceMappingURL=analyser.js.map