"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 });
exports.Linter = void 0;
const node_path_1 = require("node:path");
const node_url_1 = require("node:url");
const child_process_1 = require("child_process");
const LSP = require("vscode-languageserver/node");
const async_1 = require("../util/async");
const logger_1 = require("../util/logger");
const shebang_1 = require("../util/shebang");
const config_1 = require("./config");
const types_1 = require("./types");
const SUPPORTED_BASH_DIALECTS = ['sh', 'bash', 'dash', 'ksh'];
const DEBOUNCE_MS = 500;
class Linter {
    constructor({ cwd, executablePath }) {
        this._canLint = true;
        this.cwd = cwd || process.cwd();
        this.executablePath = executablePath;
        this.uriToDebouncedExecuteLint = Object.create(null);
    }
    get canLint() {
        return this._canLint;
    }
    lint(document_1, sourcePaths_1) {
        return __awaiter(this, arguments, void 0, function* (document, sourcePaths, additionalShellCheckArguments = []) {
            if (!this._canLint) {
                return { diagnostics: [], codeActions: {} };
            }
            const { uri } = document;
            let debouncedExecuteLint = this.uriToDebouncedExecuteLint[uri];
            if (!debouncedExecuteLint) {
                debouncedExecuteLint = (0, async_1.debounce)(this.executeLint.bind(this), DEBOUNCE_MS);
                this.uriToDebouncedExecuteLint[uri] = debouncedExecuteLint;
            }
            return debouncedExecuteLint(document, sourcePaths, additionalShellCheckArguments);
        });
    }
    executeLint(document_1, sourcePaths_1) {
        return __awaiter(this, arguments, void 0, function* (document, sourcePaths, additionalShellCheckArguments = []) {
            const documentText = document.getText();
            const shellDialect = guessShellDialect({
                documentText,
                uri: document.uri,
            });
            if (shellDialect && !SUPPORTED_BASH_DIALECTS.includes(shellDialect)) {
                // We found a dialect that isn't supported by ShellCheck.
                return { diagnostics: [], codeActions: {} };
            }
            // NOTE: that ShellCheck actually does shebang parsing, but we manually
            // do it here in order to fallback to bash for files without a shebang.
            // This enables parsing files with a bash syntax, but could yield false positives.
            const shellName = shellDialect && SUPPORTED_BASH_DIALECTS.includes(shellDialect)
                ? shellDialect
                : 'bash';
            const result = yield this.runShellCheck(documentText, shellName, [...sourcePaths, (0, node_path_1.dirname)((0, node_url_1.fileURLToPath)(document.uri))], additionalShellCheckArguments);
            if (!this._canLint) {
                return { diagnostics: [], codeActions: {} };
            }
            // Clean up the debounced function
            delete this.uriToDebouncedExecuteLint[document.uri];
            return mapShellCheckResult({ uri: document.uri, result });
        });
    }
    runShellCheck(documentText_1, shellName_1, sourcePaths_1) {
        return __awaiter(this, arguments, void 0, function* (documentText, shellName, sourcePaths, additionalArgs = []) {
            const sourcePathsArgs = sourcePaths
                .map((folder) => folder.trim())
                .filter((folderName) => folderName)
                .map((folderName) => `--source-path=${folderName}`);
            const args = [
                `--shell=${shellName}`,
                '--format=json1',
                '--external-sources',
                ...sourcePathsArgs,
                ...additionalArgs,
            ];
            logger_1.logger.debug(`ShellCheck: running "${this.executablePath} ${args.join(' ')}"`);
            let out = '';
            let err = '';
            const proc = new Promise((resolve, reject) => {
                const proc = (0, child_process_1.spawn)(this.executablePath, [...args, '-'], { cwd: this.cwd });
                proc.on('error', reject);
                proc.on('close', resolve);
                proc.stdout.on('data', (data) => (out += data));
                proc.stderr.on('data', (data) => (err += data));
                proc.stdin.on('error', () => {
                    // NOTE: Ignore STDIN errors in case the process ends too quickly, before we try to
                    // write. If we write after the process ends without this, we get an uncatchable EPIPE.
                    // This is solved in Node >= 15.1 by the "on('spawn', ...)" event, but we need to
                    // support earlier versions.
                });
                proc.stdin.end(documentText);
            });
            // NOTE: do we care about exit code? 0 means "ok", 1 possibly means "errors",
            // but the presence of parseable errors in the output is also sufficient to
            // distinguish.
            let exit;
            try {
                exit = yield proc;
            }
            catch (e) {
                // TODO: we could do this up front?
                if (e.code === 'ENOENT') {
                    // shellcheck path wasn't found, don't try to lint any more:
                    logger_1.logger.warn(`ShellCheck: disabling linting as no executable was found at path '${this.executablePath}'`);
                    this._canLint = false;
                    return { comments: [] };
                }
                throw new Error(`ShellCheck: failed with code ${exit}: ${e}\nout:\n${out}\nerr:\n${err}`);
            }
            let raw;
            try {
                raw = JSON.parse(out);
            }
            catch (e) {
                throw new Error(`ShellCheck: json parse failed with error ${e}\nout:\n${out}\nerr:\n${err}`);
            }
            return types_1.ShellCheckResultSchema.parse(raw);
        });
    }
}
exports.Linter = Linter;
function mapShellCheckResult({ uri, result }) {
    const diagnostics = [];
    const codeActions = {};
    for (const comment of result.comments) {
        const range = LSP.Range.create({
            line: comment.line - 1,
            character: comment.column - 1,
        }, {
            line: comment.endLine - 1,
            character: comment.endColumn - 1,
        });
        const id = `shellcheck|${comment.code}|${range.start.line}:${range.start.character}-${range.end.line}:${range.end.character}`;
        const diagnostic = {
            message: comment.message,
            severity: config_1.LEVEL_TO_SEVERITY[comment.level] || LSP.DiagnosticSeverity.Error,
            code: `SC${comment.code}`,
            source: 'shellcheck',
            range,
            codeDescription: {
                href: `https://www.shellcheck.net/wiki/SC${comment.code}`,
            },
            tags: config_1.CODE_TO_TAGS[comment.code],
            data: {
                id,
            },
        };
        diagnostics.push(diagnostic);
        const codeAction = CodeActionProvider.getCodeAction({
            comment,
            diagnostics: [diagnostic],
            uri,
        });
        if (codeAction) {
            codeActions[id] = codeAction;
        }
    }
    return { diagnostics, codeActions };
}
/**
 * Code has been adopted from https://github.com/vscode-shellcheck/vscode-shellcheck/
 * and modified to fit the needs of this project.
 *
 * The MIT License (MIT)
 * Copyright (c) Timon Wong
 */
class CodeActionProvider {
    static getCodeAction({ comment, diagnostics, uri, }) {
        const { code, fix } = comment;
        if (!fix || fix.replacements.length === 0) {
            return null;
        }
        const { replacements } = fix;
        if (replacements.length === 0) {
            return null;
        }
        const edits = this.getTextEdits(replacements);
        if (!edits.length) {
            return null;
        }
        return {
            title: `Apply fix for SC${code}`,
            diagnostics,
            edit: {
                changes: {
                    [uri]: edits,
                },
            },
            kind: LSP.CodeActionKind.QuickFix,
        };
    }
    static getTextEdits(replacements) {
        if (replacements.length === 1) {
            return [this.getTextEdit(replacements[0])];
        }
        else if (replacements.length === 2) {
            return [this.getTextEdit(replacements[1]), this.getTextEdit(replacements[0])];
        }
        return [];
    }
    static getTextEdit(replacement) {
        const startPos = LSP.Position.create(replacement.line - 1, replacement.column - 1);
        const endPos = LSP.Position.create(replacement.endLine - 1, replacement.endColumn - 1);
        return {
            range: LSP.Range.create(startPos, endPos),
            newText: replacement.replacement,
        };
    }
}
function guessShellDialect({ documentText, uri }) {
    return uri.endsWith('.zsh') ? 'zsh' : (0, shebang_1.analyzeShebang)(documentText).shellDialect;
}
//# sourceMappingURL=index.js.map