"use strict";
/**
 * @license
 * Copyright (c) 2016 The Polymer Project Authors. All rights reserved.
 * This code may only be used under the BSD style license found at
 * http://polymer.github.io/LICENSE.txt
 * The complete set of authors may be found at
 * http://polymer.github.io/AUTHORS.txt
 * The complete set of contributors may be found at
 * http://polymer.github.io/CONTRIBUTORS.txt
 * Code distributed by Google as part of the polymer project is also
 * subject to an additional IP rights grant found at
 * http://polymer.github.io/PATENTS.txt
 */
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    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) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
var __asyncValues = (this && this.__asyncValues) || function (o) {
    if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
    var m = o[Symbol.asyncIterator];
    return m ? m.call(o) : typeof __values === "function" ? __values(o) : o[Symbol.iterator]();
};
var __await = (this && this.__await) || function (v) { return this instanceof __await ? (this.v = v, this) : new __await(v); }
var __asyncGenerator = (this && this.__asyncGenerator) || function (thisArg, _arguments, generator) {
    if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
    var g = generator.apply(thisArg, _arguments || []), i, q = [];
    return i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i;
    function verb(n) { if (g[n]) i[n] = function (v) { return new Promise(function (a, b) { q.push([n, v, a, b]) > 1 || resume(n, v); }); }; }
    function resume(n, v) { try { step(g[n](v)); } catch (e) { settle(q[0][3], e); } }
    function step(r) { r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r);  }
    function fulfill(value) { resume("next", value); }
    function reject(value) { resume("throw", value); }
    function settle(f, v) { if (f(v), q.shift(), q.length) resume(q[0][0], q[0][1]); }
};
Object.defineProperty(exports, "__esModule", { value: true });
const chalk = require("chalk");
const chokidar = require("chokidar");
const globby = require("globby");
const fs = require("mz/fs");
const path = require("path");
const logging = require("plylog");
const polymer_analyzer_1 = require("polymer-analyzer");
const warning_filter_1 = require("polymer-analyzer/lib/warning/warning-filter");
const warning_printer_1 = require("polymer-analyzer/lib/warning/warning-printer");
const lintLib = require("polymer-linter");
const command_1 = require("../commands/command");
const util_1 = require("../util");
const logger = logging.getLogger('cli.lint');
function lint(options, config) {
    return __awaiter(this, void 0, void 0, function* () {
        const lintOptions = (config.lint || {});
        const ruleCodes = options.rules || lintOptions.rules;
        if (ruleCodes === undefined) {
            logger.warn(`You must state which lint rules to use. You can use --rules, ` +
                `but for a project it's best to use polymer.json. e.g.

{
  "lint": {
    "rules": ["polymer-2"]
  }
}`);
            return new command_1.CommandResult(1);
        }
        const rules = lintLib.registry.getRules(ruleCodes || lintOptions.rules);
        const filter = new warning_filter_1.WarningFilter({
            warningCodesToIgnore: new Set(lintOptions.ignoreWarnings || []),
            minimumSeverity: polymer_analyzer_1.Severity.WARNING
        });
        const analyzer = new polymer_analyzer_1.Analyzer({
            urlLoader: new polymer_analyzer_1.FSUrlLoader(config.root),
            urlResolver: new polymer_analyzer_1.PackageUrlResolver({
                componentDir: config.componentDir,
            }),
        });
        const linter = new lintLib.Linter(rules, analyzer);
        if (options.watch) {
            return watchLoop(analyzer, linter, options, config, filter);
        }
        else {
            return run(analyzer, linter, options, config, filter);
        }
    });
}
exports.lint = lint;
/**
 * Run a single pass of the linter, and then report the results or fix warnings
 * as requested by `options`.
 *
 * In a normal run this is called once and then it's done. When running with
 * `--watch` this function is called each time files on disk change.
 */
function run(analyzer, linter, options, config, filter, editActionsToAlwaysApply = new Set(options.edits || []), watcher) {
    return __awaiter(this, void 0, void 0, function* () {
        let warnings;
        if (options.input) {
            warnings = yield linter.lint(yield globby(options.input));
        }
        else {
            warnings = yield linter.lintPackage();
        }
        const analysis = warnings.analysis;
        const filtered = warnings.filter((w) => !filter.shouldIgnore(w));
        if (options.fix) {
            const changedFiles = yield fix(filtered, options, config, analyzer, analysis, editActionsToAlwaysApply);
            if (watcher) {
                // Some file watcher interfaces won't notice this change immediately after
                // the one that initiated this lint run. Ensure that we notice these
                // changes.
                for (const changedFile of changedFiles) {
                    watcher.ensureChangeIsNoticed(changedFile);
                }
            }
            if (changedFiles.size === 0 && options.reportIfNoFix) {
                yield report(filtered);
            }
        }
        else {
            return report(filtered);
        }
    });
}
function watchLoop(analyzer, linter, options, config, filter) {
    return __awaiter(this, void 0, void 0, function* () {
        let analysis;
        if (options.input) {
            analysis = yield analyzer.analyze(options.input);
        }
        else {
            analysis = yield analyzer.analyzePackage();
        }
        /** Remember the user's preferences across runs. */
        const lintActionsToAlwaysApply = new Set(options.edits || []);
        const paths = new Set([...analysis.getFeatures({ kind: 'document' })].map((d) => d.url));
        const watcher = new FilesystemChangeStream(chokidar.watch([...paths], { persistent: true }));
        try {
            for (var watcher_1 = __asyncValues(watcher), watcher_1_1; watcher_1_1 = yield watcher_1.next(), !watcher_1_1.done;) {
                const changeBatch = yield watcher_1_1.value;
                yield analyzer.filesChanged([...changeBatch]);
                yield run(analyzer, linter, Object.assign({}, options, { reportIfNoFix: true }), config, filter, lintActionsToAlwaysApply, 
                // We pass the watcher to run() so that it can inform the watcher
                // about files that it changes when fixing wanings.
                watcher);
                console.log('\nLint pass complete, waiting for filesystem changes.\n\n');
            }
        }
        catch (e_1_1) { e_1 = { error: e_1_1 }; }
        finally {
            try {
                if (watcher_1_1 && !watcher_1_1.done && (_a = watcher_1.return)) yield _a.call(watcher_1);
            }
            finally { if (e_1) throw e_1.error; }
        }
        var e_1, _a;
    });
}
/**
 * Converts the event-based FSWatcher into a batched async iterator.
 */
class FilesystemChangeStream {
    constructor(watcher) {
        this.nextBatch = new Set();
        this.alertWaiter = undefined;
        this.outOfBandNotices = undefined;
        watcher.on('change', (path) => {
            this.noticeChange(path);
        });
        watcher.on('unlink', (path) => {
            this.noticeChange(path);
        });
    }
    /**
     * Called when we have noticed a change to the file. Ensures that the file
     * will be in the next batch of changes.
     */
    noticeChange(path) {
        this.nextBatch.add(path);
        if (this.alertWaiter) {
            this.alertWaiter();
            this.alertWaiter = undefined;
        }
        if (this.outOfBandNotices) {
            this.outOfBandNotices.delete(path);
        }
    }
    /**
     * Ensures that we will notice a change to the given path, without creating
     * duplicated change notices if the normal filesystem watcher also notices
     * a change to the same path soon.
     *
     * This is a way to notify the watcher when we change a file in response
     * to another change. The FS event watcher used on linux will ignore our
     * change, as it gets grouped in with the change that we were responding to.
     */
    ensureChangeIsNoticed(path) {
        if (!this.outOfBandNotices) {
            const notices = new Set();
            this.outOfBandNotices = notices;
            setTimeout(() => {
                for (const path of notices) {
                    this.noticeChange(path);
                }
                this.outOfBandNotices = undefined;
            }, 100);
        }
        this.outOfBandNotices.add(path);
    }
    /**
     * Yields batches of filenames.
     *
     * Each batch of files are those changes that have changed since the last
     * batch. Never yields an empty batch, but waits until at least one change is
     * noticed.
     */
    [Symbol.asyncIterator]() {
        return __asyncGenerator(this, arguments, function* _a() {
            yield new Set();
            while (true) {
                /**
                 * If there are changes, yield them. If there are not, wait until
                 * there are.
                 */
                if (this.nextBatch.size > 0) {
                    const batch = this.nextBatch;
                    this.nextBatch = new Set();
                    yield batch;
                }
                else {
                    const waitingPromise = new Promise((resolve) => {
                        this.alertWaiter = resolve;
                    });
                    yield __await(waitingPromise);
                }
            }
        });
    }
}
/**
 * Report a friendly description of the given warnings to stdout.
 */
function report(warnings) {
    return __awaiter(this, void 0, void 0, function* () {
        const printer = new warning_printer_1.WarningPrinter(process.stdout, { verbosity: 'full', color: true });
        yield printer.printWarnings(warnings);
        if (warnings.length > 0) {
            let message = '';
            const errors = warnings.filter((w) => w.severity === polymer_analyzer_1.Severity.ERROR);
            const warningLevelWarnings = warnings.filter((w) => w.severity === polymer_analyzer_1.Severity.WARNING);
            const infos = warnings.filter((w) => w.severity === polymer_analyzer_1.Severity.INFO);
            const fixable = warnings.filter((w) => !!w.fix).length;
            const hasEditAction = (w) => !!(w.actions && w.actions.find((a) => a.kind === 'edit'));
            const editable = warnings.filter(hasEditAction).length;
            if (errors.length > 0) {
                message += ` ${errors.length} ` +
                    `${chalk.red('error' + plural(errors.length))}`;
            }
            if (warningLevelWarnings.length > 0) {
                message += ` ${warningLevelWarnings.length} ` +
                    `${chalk.yellow('warning' + plural(warnings.length))}`;
            }
            if (infos.length > 0) {
                message += ` ${infos.length} ${chalk.green('info')} message` +
                    plural(infos.length);
            }
            if (fixable > 0) {
                message += `. ${fixable} can be automatically fixed with --fix`;
                if (editable > 0) {
                    message +=
                        ` and ${editable} ${plural(editable, 'have', 'has')} edit actions`;
                }
            }
            else if (editable > 0) {
                message += `. ${editable} ${plural(editable, 'have', 'has')} ` +
                    `edit actions, run with --fix for more info`;
            }
            console.log(`\n\nFound${message}.`);
            return new command_1.CommandResult(1);
        }
    });
}
/**
 * Fix all fixable warnings given. Changes files on the filesystem.
 *
 * Reports a summary of the fixes made to stdout.
 */
function fix(warnings, options, config, analyzer, analysis, editActionsToAlwaysApply) {
    return __awaiter(this, void 0, void 0, function* () {
        const edits = yield getPermittedEdits(warnings, options, editActionsToAlwaysApply);
        if (edits.length === 0) {
            const editCount = warnings.filter((w) => !!w.actions).length;
            if (!options.prompt && editCount) {
                console.log(`No fixes to apply. ` +
                    `${editCount} action${plural(editCount)} may be applied though. ` +
                    `Run in an interactive terminal ` +
                    `with --prompt=true for more details.`);
            }
            else {
                console.log(`No fixes to apply.`);
            }
            return new Set();
        }
        const { appliedEdits, incompatibleEdits, editedFiles } = yield polymer_analyzer_1.applyEdits(edits, polymer_analyzer_1.makeParseLoader(analyzer, analysis));
        for (const [newPath, newContents] of editedFiles) {
            yield fs.writeFile(path.join(config.root, newPath), newContents, { encoding: 'utf8' });
        }
        const appliedChangeCountByFile = countEditsByFile(appliedEdits);
        const incompatibleChangeCountByFile = countEditsByFile(incompatibleEdits);
        for (const [file, count] of appliedChangeCountByFile) {
            console.log(`  Made ${count} change${plural(count)} to ${file}`);
        }
        if (incompatibleEdits.length > 0) {
            console.log('\n');
            for (const [file, count] of incompatibleChangeCountByFile) {
                console.log(`  ${count} incompatible changes in ${file}`);
            }
            console.log(`\nFixed ${appliedEdits.length} ` +
                `warning${plural(appliedEdits.length)}. ` +
                `${incompatibleEdits.length} fixes had conflicts with other fixes. ` +
                `Rerunning the command may apply them.`);
        }
        else {
            console.log(`\nFixed ${appliedEdits.length} ` +
                `warning${plural(appliedEdits.length)}.`);
        }
        const changedFiles = new Set();
        for (const edit of appliedEdits) {
            for (const replacement of edit) {
                changedFiles.add(replacement.range.file);
            }
        }
        return changedFiles;
    });
}
/**
 * Computes a map of file path to the count of changes made to that file.
 */
function countEditsByFile(edits) {
    const changeCountByFile = new Map();
    for (const edit of edits) {
        for (const replacement of edit) {
            changeCountByFile.set(replacement.range.file, (changeCountByFile.get(replacement.range.file) || 0) + 1);
        }
    }
    return changeCountByFile;
}
function plural(n, pluralVal = 's', singularVal = '') {
    if (n === 1) {
        return singularVal;
    }
    return pluralVal;
}
/**
 * Returns edits from fixes and from edit actions with explicit user consent
 * (including prompting the user if we're connected to an interactive
 * terminal).
 */
function getPermittedEdits(warnings, options, editActionsToAlwaysApply) {
    return __awaiter(this, void 0, void 0, function* () {
        const edits = [];
        for (const warning of warnings) {
            if (warning.fix) {
                edits.push(warning.fix);
            }
            for (const action of warning.actions || []) {
                if (action.kind === 'edit') {
                    if (editActionsToAlwaysApply.has(action.code)) {
                        edits.push(action.edit);
                        continue;
                    }
                    if (options.prompt) {
                        const answer = yield askUserForConsentToApplyEditAction(action, warning);
                        switch (answer) {
                            case 'skip':
                                continue;
                            case 'apply-all':
                                editActionsToAlwaysApply.add(action.code);
                            // fall through
                            case 'apply':
                                edits.push(action.edit);
                                break;
                            default:
                                const never = answer;
                                throw new Error(`Got unknown user consent result: ${never}`);
                        }
                    }
                }
            }
        }
        return edits;
    });
}
function askUserForConsentToApplyEditAction(action, warning) {
    return __awaiter(this, void 0, void 0, function* () {
        const choices = [
            {
                value: 'skip',
                name: 'Do not apply this edit',
            },
            {
                value: 'apply',
                name: 'Apply this edit',
            },
            {
                value: 'apply-all',
                name: `Apply all edits like this [${action.code}]`,
            }
        ];
        const message = `
This warning can be addressed with an edit:
${util_1.indent(warning.toString(), '    ')}

The edit is:

${util_1.indent(action.description, '    ')}

What should be done?
`.trim();
        return yield util_1.prompt({ message, choices });
    });
}
