node-mutation.js

"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());
    });
};
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const debug_1 = __importDefault(require("debug"));
const typescript_1 = __importDefault(require("./adapter/typescript"));
const espree_1 = __importDefault(require("./adapter/espree"));
const gonzales_pe_1 = __importDefault(require("./adapter/gonzales-pe"));
const strategy_1 = __importDefault(require("./strategy"));
const action_1 = require("./action");
const error_1 = require("./error");
class NodeMutation {
    /**
     * Configure NodeMutation
     * @static
     * @param options {Object}
     * @param options.strategy {Strategy} - strategy, default is Strategy.THROW_ERROR
     * @param options.tabWidth {Number} - tab width, default is 2
     */
    static configure(options) {
        if (options.strategy) {
            this.strategy = options.strategy;
        }
        if (options.tabWidth) {
            this.tabWidth = options.tabWidth;
        }
    }
    /**
     * Initialize a NodeMutation
     * @param source {string} - file source.
     * @param {object} options
     * @param {Adapter<T>} options.adapter - adapter to parse the node
     */
    constructor(source, { adapter }) {
        this.source = source;
        this.actions = [];
        this.adapter = this.getAdapterInstance(adapter);
    }
    /**
     * Append code to the ast node.
     * @param node {T} - ast node
     * @param code {string} - new code to append
     * @example
     * source code of the ast node is
     * ```
     * class FooBar {
     *   foo() {}
     * }
     * ```
     * then we call
     * ```
     * mutation.append(node, "bar() {}");
     * ```
     * the source code will be rewritten to
     * ```
     * class FooBar {
     *   foo() {}
     *   bar() {}
     * }
     * ```
     */
    append(node, code) {
        this.actions.push(new action_1.AppendAction(node, code, { adapter: this.adapter }).process());
    }
    /**
     * Delete source code of the child ast node
     * @param node {T} - current ast node
     * @param selectors {string|string[]} - selectors to find chid ast nodes
     * @param options {DeleteOptions}
     * @example
     * source code of the ast node is
     * ```
     * this.foo.bind(this)
     * ```
     * then we call
     * ```
     * mutation.delete(["expression.expression.dot", "expression.expression.name", "expression.arguments"])
     * ```
     * the source code will be rewritten to
     * ```
     * this.foo
     * ```
     */
    delete(node, selectors, options = {}) {
        this.actions.push(new action_1.DeleteAction(node, selectors, Object.assign(Object.assign({}, options), { adapter: this.adapter })).process());
    }
    /**
     * Group actions, it works both sync and async.
     * @param func {Function} - actions in the function will be grouped
     */
    group(func) {
        return __awaiter(this, void 0, void 0, function* () {
            const currentActions = this.actions;
            const groupAction = new action_1.GroupAction({ adapter: this.adapter });
            this.actions = [];
            const result = func.call(this);
            if (result instanceof Promise) {
                yield result;
            }
            groupAction.actions = this.actions;
            this.actions = currentActions;
            this.actions.push(groupAction.process());
        });
    }
    /**
     * Insert options
     * @typedef {Object} InsertOptions
     * @property {string} [at = "end"] - position to insert, "beginning" or "end"
     * @property {string} [to] - selector to find the child ast node
     */
    /**
     * Insert code to the ast node.
     * @param node {T} - ast node
     * @param code {string} - new code to insert
     * @param options {InsertOptions}
     * @example
     * source code of the ast node is
     * ```
     * this.foo
     * ```
     * then we call
     * ```
     * mutation.insert(node, "::", { at: "beginning" });
     * ```
     * the source code will be rewritten to
     * ```
     * ::this.foo
     * ```
     * if we call
     * ```
     * mutation.insert(node, ".bar", { to: "expression.expression" })
     * ```
     * the source code will be rewritten to
     * ```
     * this.foo.bar
     * ```
     */
    insert(node, code, options = { at: "end" }) {
        this.actions.push(new action_1.InsertAction(node, code, Object.assign(Object.assign({}, options), { adapter: this.adapter })).process());
    }
    /**
     * Indent the ast node.
     * @param node {T} - ast node
     * @param options {IndentOptions}
     * @example
     * source code of the ast node is
     * ```
     * class FooBar {
     * }
     * ```
     * then we call
     * ```
     * mutation.indent(node, { tabSize: 1 });
     * ```
     * the source code will be rewritten to
     * ```
     *   class FooBar {
     *   }
     * ```
     */
    indent(node, options = { tabSize: 1 }) {
        this.actions.push(new action_1.IndentAction(node, Object.assign(Object.assign({}, options), { adapter: this.adapter })).process());
    }
    /**
     * No operation.
     * @param node {T} - ast node
     */
    noop(node) {
        this.actions.push(new action_1.NoopAction(node, { adapter: this.adapter }).process());
    }
    /**
     * Prepend code to the ast node.
     * @param node {T} - ast node
     * @param code {string} - new code to prepend
     * @example
     * source code of the ast node is
     * ```
     * class FooBar {
     *   foo() {}
     * }
     * ```
     * then we call
     * ```
     * mutation.prepend(node, "bar() {}");
     * ```
     * the source code will be rewritten to
     * ```
     * class FooBar {
     *   bar() {}
     *   foo() {}
     * }
     * ```
     */
    prepend(node, code) {
        this.actions.push(new action_1.PrependAction(node, code, { adapter: this.adapter }).process());
    }
    /**
     * Remove source code of the ast node
     * @param node {T} - ast node
     * @param options {RemoveOptions}
     * @example
     * source code of the ast node is
     * ```
     * this.foo.bind(this)
     * ```
     * then we call
     * ```
     * mutation.remove()
     * ```
     * the source code will be completely removed
     */
    remove(node, options = {}) {
        this.actions.push(new action_1.RemoveAction(node, Object.assign(Object.assign({}, options), { adapter: this.adapter })).process());
    }
    /**
     * Replace options
     * @typedef {Object} ReplaceOptions
     * @property {string} with - new code to replace with
     */
    /**
     * Replace child node of the ast node with new code
     * @param node {T} - current ast node
     * @param selectors {string|string[]} - selectors to find chid ast nodes
     * @param options {ReplaceOptions}
     * @example
     * source code of the ast node is
     * ```
     * class FooBar {}
     * ```
     * then we call
     * ```
     * mutation.replace(node, "name", { with: "Synvert" });
     * ```
     * the source code will be rewritten to
     * ```
     * class Synvert {}
     * ```
     */
    replace(node, selectors, options) {
        this.actions.push(new action_1.ReplaceAction(node, selectors, Object.assign(Object.assign({}, options), { adapter: this.adapter })).process());
    }
    /**
     * Replace the ast node with new code
     * @param node {T} - ast node
     * @param code {string} - new code to replace
     * @example
     * source code of the ast node is
     * ```
     * !!foobar
     * ```
     * then we call
     * ```
     * mutation.replaceWith(node, "Boolean({{expression.operand.operand}})");
     * ```
     * the source code will be rewritten to
     * ```
     * Boolean(foobar)
     * ```
     */
    replaceWith(node, code) {
        this.actions.push(new action_1.ReplaceWithAction(node, code, { adapter: this.adapter }).process());
    }
    /**
     * Wrap the ast node with prefix and suffix.
     * @param node {T} - ast node
     * @param prefix {string} - prefix
     * @param suffix {string} - suffix
     * @param newLine {boolean} - if true, the prefix and suffix will be wrapped in a new line
     * @example
     * source code of the ast node is
     * ```
     * console.log('foo)
     * ```
     * then we call
     * ```
     * mutation.wrap(node, { prefix: "function logFoo() {", suffix: "}", newLine: true });
     * ```
     * the source code will be rewritten to
     * ```
     * function logFoo() {
     *   console.log('foo')
     * }
     * ```
     */
    wrap(node, { prefix, suffix, newLine }) {
        if (newLine) {
            const indentation = this.adapter.getStartLoc(node).column;
            this.group(() => {
                this.insert(node, prefix + "\n" + (' '.repeat(indentation)), { at: "beginning" });
                this.insert(node, "\n" + (' '.repeat(indentation)) + suffix, { at: "end" });
                this.indent(node);
            });
        }
        else {
            this.group(() => {
                this.insert(node, prefix, { at: "beginning" });
                this.insert(node, suffix, { at: "end" });
            });
        }
    }
    /**
     * Process Result
     * @typedef {Object} ProcessResult
     * @property {boolean} affected - if the source code is affected
     * @property {boolean} conflicted - if the actions are conflicted
     * @property {string} [newSource] - the new generated source code
     */
    /**
     * Rewrite the source code based on all actions.
     *
     * If there's an action range conflict,
     * it will raise a ConflictActionError if strategy is set to THROW_ERROR,
     * it will process all non conflicted actions and return `{ conflicted: true }`
     * @returns {ProcessResult}
     */
    process() {
        this.actions = this.optimizeGroupActions(this.actions);
        const flattenActions = this.flatActions(this.actions);
        if (flattenActions.length == 0) {
            return { affected: false, conflicted: false };
        }
        const sortedActions = this.sortFlattenActions(flattenActions);
        const conflictActions = this.getConflictActions(sortedActions);
        if (conflictActions.length > 0 && this.isStrategy(strategy_1.default.THROW_ERROR)) {
            throw new error_1.ConflictActionError();
        }
        const actions = this.sortFlattenActions(this.flatActions(this.getFilteredActions(conflictActions)));
        const newSource = this.rewriteSource(this.source, actions);
        return {
            affected: true,
            conflicted: conflictActions.length !== 0,
            newSource
        };
    }
    /**
     * Action
     * @typedef {Object} Action
     * @property {number} start - start position of the action
     * @property {number} end - end position of the action
     * @property {string} [newCode] - the new generated source code
     */
    /**
     * Test Result
     * @typedef {Object} TestResult
     * @property {boolean} affected - if the source code is affected
     * @property {boolean} conflicted - if the actions are conflicted
     * @property {Action[]} actions - actions to be processed
     */
    /**
     * Return the actions.
     *
     * If there's an action range conflict,
     * it will raise a ConflictActionError if strategy is set to THROW_ERROR,
     * it will process all non conflicted actions and return `{ conflicted: true }`
     * @returns {TestResult} if actions are conflicted and the actions
     */
    test() {
        this.actions = this.optimizeGroupActions(this.actions);
        const flattenActions = this.flatActions(this.actions);
        if (flattenActions.length == 0) {
            return { affected: false, conflicted: false, actions: [] };
        }
        const sortedActions = this.sortFlattenActions(flattenActions);
        const conflictActions = this.getConflictActions(sortedActions);
        if (conflictActions.length > 0 && this.isStrategy(strategy_1.default.THROW_ERROR)) {
            throw new error_1.ConflictActionError();
        }
        const actions = this.sortActions(this.getFilteredActions(conflictActions));
        return { affected: true, conflicted: conflictActions.length !== 0, actions };
    }
    /**
     * Optimizes a group of actions by recursively optimizing its sub-actions.
     * If a group action contains only one action, it replaces the group action with that action.
     * If a group action contains more than one action, it optimizes its sub-actions.
     * @private
     * @param {Action[]} actions
     * @returns {Action[]} optimized actions
     */
    optimizeGroupActions(actions) {
        return actions.flatMap(action => {
            if (action.type === 'group') {
                // If the group action contains only one action, replace the group action with that action
                if (action.actions.length === 1) {
                    return this.optimizeGroupActions(action.actions);
                }
                // If the group action contains more than one action, optimize its sub-actions
                action.actions = this.optimizeGroupActions(action.actions);
            }
            return action;
        });
    }
    /**
     * It flattens a series of actions by removing any GroupAction objects that contain only a single action. This is done recursively.
     * @private
     * @param {Action[]} actions
     * @returns {Action[]} sorted actions
     */
    flatActions(actions) {
        const flattenActions = [];
        actions.forEach(action => {
            if (action.type === "group") {
                flattenActions.push(...this.flatActions(action.actions));
            }
            else {
                flattenActions.push(action);
            }
        });
        return flattenActions;
    }
    /**
     * Sort actions by start position and end position.
     * @private
     * @param {Action[]} flattenActions
     * @returns {Action[]} sorted actions
     */
    sortFlattenActions(flattenActions) {
        return flattenActions.sort(this.compareActions);
    }
    /**
     * Recursively sort actions by start position and end position.
     * @private
     * @param {Action[]} flattenActions
     * @returns {Action[]} sorted actions
     */
    sortActions(actions) {
        actions.sort(this.compareActions);
        actions.forEach(action => {
            if (action.type === "group") {
                this.sortActions(action.actions);
            }
        });
        return actions;
    }
    /**
     * Action sort function.
     * @private
     * @param {Action} actionA
     * @param {Action} actionB
     * @returns {number} returns 1 if actionA goes before actionB, -1 if actionA goes after actionB
     */
    compareActions(actionA, actionB) {
        if (actionA.start > actionB.start)
            return 1;
        if (actionA.start < actionB.start)
            return -1;
        if (actionA.end > actionB.end)
            return 1;
        if (actionA.end < actionB.end)
            return -1;
        if (actionA.conflictPosition && actionB.conflictPosition) {
            if (actionA.conflictPosition > actionB.conflictPosition)
                return 1;
            if (actionA.conflictPosition < actionB.conflictPosition)
                return -1;
        }
        return 0;
    }
    /**
     * Rewrite source code with actions.
     * @param source {string} source code
     * @param actions {Action[]} actions
     * @returns {string} new source code
     */
    rewriteSource(source, actions) {
        actions.reverse().forEach((action) => {
            if (action.type === "group") {
                source = this.rewriteSource(source, action.actions);
            }
            else if (typeof action.newCode !== "undefined") {
                source =
                    source.slice(0, action.start) +
                        action.newCode +
                        source.slice(action.end);
            }
        });
        return source;
    }
    /**
     * It changes source code from bottom to top, and it can change source code twice at the same time,
     * So if there is an overlap between two actions, it removes the conflict actions and operate them in the next loop.
     * @private
     * @param {Action[]} actions
     * @returns {Action[]} conflict actions
     */
    getConflictActions(actions) {
        let i = actions.length - 1;
        let j = i - 1;
        const conflictActions = [];
        if (i < 0)
            return [];
        let beginPos = actions[i].start;
        let endPos = actions[i].end;
        while (j > -1) {
            if (beginPos < actions[j].end) {
                conflictActions.push(actions[j]);
                (0, debug_1.default)("node-mutation")(`Conflict ${actions[j].type}[${actions[j].start}-${actions[j].end}]:${actions[j].newCode}`);
            }
            else {
                i = j;
                beginPos = actions[i].start;
                endPos = actions[i].end;
            }
            j--;
        }
        return conflictActions;
    }
    /**
     * It filters conflict actions from actions.
     * @private
     * @param {Action[]} conflictActions
     * @returns {Action[]} filtered actions
     */
    getFilteredActions(conflictActions) {
        return this.actions.filter(action => {
            if (action.type === 'group') {
                // If all child-actions of a group action are conflicted, remove the group action
                return action.actions.every(childAction => !conflictActions.includes(childAction));
            }
            else {
                return !conflictActions.includes(action);
            }
        });
    }
    isStrategy(strategy) {
        return !!NodeMutation.strategy && (NodeMutation.strategy & strategy) === strategy;
    }
    getAdapterInstance(adapter) {
        switch (adapter) {
            case "espree":
                return new espree_1.default();
            case "typescript":
                return new typescript_1.default();
            case "gonzales-pe":
                return new gonzales_pe_1.default();
            default:
                throw new Error(`Adapter "${adapter}" is not supported.`);
        }
    }
}
NodeMutation.strategy = strategy_1.default.THROW_ERROR;
NodeMutation.tabWidth = 2;
exports.default = NodeMutation;