adobe/brackets

View on GitHub
src/extensions/default/JavaScriptRefactoring/ExtractToFunction.js

Summary

Maintainability
D
2 days
Test Coverage
/*
* Copyright (c) 2013 - present Adobe Systems Incorporated. All rights reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*
*/

define(function(require, exports, module) {
    'use strict';

    var ASTWalker           = brackets.getModule("thirdparty/acorn/dist/walk"),
        EditorManager       = brackets.getModule("editor/EditorManager"),
        _                   = brackets.getModule("thirdparty/lodash"),
        StringUtils         = brackets.getModule("utils/StringUtils"),
        Session             = brackets.getModule("JSUtils/Session"),
        RefactoringUtils    = require("RefactoringUtils"),
        Strings             = brackets.getModule("strings"),
        InlineMenu          = brackets.getModule("widgets/InlineMenu").InlineMenu;

    var template = JSON.parse(require("text!Templates.json"));

    var session = null;

    /**
     * Analyzes the code and finds values required for extract to function
     * @param {!string} text - text to be extracted
     * @param {!Array.<Scope>} - scopes
     * @param {!Scope} srcScope - source scope of the extraction
     * @param {!Scope} destScope - destination scope of the extraction
     * @param {!number} start - the start offset
     * @param {!number} end - the end offset
     * @return {!{
     *          passParams: Array.<string>,
     *          retParams: Array.<string>,
     *          thisPointerUsed: boolean,
     *          varaibleDeclarations: {} // variable-name: kind
     * }}
     */
    function analyzeCode(text, scopes, srcScope, destScope, start, end) {
        var identifiers          = {},
            inThisScope          = {},
            thisPointerUsed      = false,
            returnStatementUsed  = false,
            variableDeclarations = {},
            changedValues        = {},
            dependentValues      = {},
            ast                  = RefactoringUtils.getAST(text),
            doc                  = session.editor.document,
            restScopeStr;

        ASTWalker.full(ast, function(node) {
            var value, name;
            switch (node.type) {
                case "AssignmentExpression":
                    value = node.left;
                    break;
                case "VariableDeclarator":
                    inThisScope[node.id.name] = true;
                    value = node.init && node.id;
                    var variableDeclarationNode = RefactoringUtils.findSurroundASTNode(ast, node, ["VariableDeclaration"]);
                    variableDeclarations[node.id.name] = variableDeclarationNode.kind;
                    break;
                case "ThisExpression":
                    thisPointerUsed = true;
                    break;
                case "UpdateExpression":
                    value = node.argument;
                    break;
                case "Identifier":
                    identifiers[node.name] = true;
                    break;
                case "ReturnStatement":
                    returnStatementUsed = true;
                    break;
            }
            if (value){
                if (value.type === "MemberExpression") {
                    name = value.object.name;
                } else {
                    name = value.name;
                }
                changedValues[name] = true;
            }
        });

        if (srcScope.originNode) {
            restScopeStr = doc.getText().substr(end, srcScope.originNode.end - end);
        } else {
            restScopeStr = doc.getText().substr(end);
        }

        ASTWalker.simple(RefactoringUtils.getAST(restScopeStr), {
            Identifier: function(node) {
                var name = node.name;
                dependentValues[name] = true;
            },
            Expression: function(node) {
                if (node.type === "MemberExpression") {
                    var name = node.object.name;
                    dependentValues[name] = true;
                }
            }
        });

        var passProps = scopes.slice(srcScope.id, destScope.id).reduce(function(props, scope) {
            return _.union(props, _.keys(scope.props));
        }, []);

        var retProps = scopes.slice(srcScope.id, destScope.id + 1).reduce(function(props, scope) {
            return _.union(props, _.keys(scope.props));
        }, []);

        return {
            passParams:           _.intersection(_.difference(_.keys(identifiers), _.keys(inThisScope)), passProps),
            retParams:            _.intersection( _.keys(changedValues), _.keys(dependentValues), retProps),
            thisPointerUsed:      thisPointerUsed,
            returnStatementUsed:  returnStatementUsed,
            variableDeclarations: variableDeclarations
        };
    }

    /**
     * Does the actual extraction. i.e Replacing the text, Creating a function
     * and multi select function names
     */
    function extract(ast, text, scopes, srcScope, destScope, start, end, isExpression) {
        var retObj               = analyzeCode(text, scopes, srcScope, destScope, start, end),
            passParams           = retObj.passParams,
            retParams            = retObj.retParams,
            thisPointerUsed      = retObj.thisPointerUsed,
            returnStatementUsed  = retObj.returnStatementUsed,
            variableDeclarations = retObj.variableDeclarations,
            doc                  = session.editor.document,
            fnBody               = text,
            fnName               = RefactoringUtils.getUniqueIdentifierName(scopes, "extracted"),
            fnDeclaration,
            fnCall;

        function appendVarDeclaration(identifier) {
            if (variableDeclarations.hasOwnProperty(identifier)) {
                 return variableDeclarations[identifier] + " " + identifier;
            }
            else {
                 return identifier;
            }
        }

        if (destScope.isClass) {
            fnCall = StringUtils.format(template.functionCall.class, fnName, passParams.join(", "));
        } else if (thisPointerUsed) {
            passParams.unshift("this");
            fnCall = StringUtils.format(template.functionCall.thisPointer, fnName, passParams.join(", "));
            passParams.shift();
        } else {
            fnCall = StringUtils.format(template.functionCall.normal, fnName, passParams.join(", "));
        }

        // Append return to the fnCall, if the extracted text contains return statement
        // Ideally in this case retParams should be empty.
        if (returnStatementUsed) {
            fnCall = "return " + fnCall;
        }

        if (isExpression) {
            fnBody = StringUtils.format(template.returnStatement.single, fnBody);
        } else {

            var retParamsStr = "";
            if (retParams.length > 1) {
                retParamsStr = StringUtils.format(template.returnStatement.multiple, retParams.join(", "));
                fnCall = "var ret = " + fnCall + ";\n";
                fnCall += retParams.map(function (param) {
                    return StringUtils.format(template.assignment, appendVarDeclaration(param),  "ret." + param);
                }).join("\n");
            } else if (retParams.length === 1) {
                retParamsStr = StringUtils.format(template.returnStatement.single, retParams.join(", "));
                fnCall = StringUtils.format(template.assignment, appendVarDeclaration(retParams[0]), fnCall);
            } else {
                fnCall += ";";
            }

            fnBody = fnBody + "\n" + retParamsStr;
        }

        if (destScope.isClass) {
            fnDeclaration = StringUtils.format(template.functionDeclaration.class, fnName, passParams.join(", "), fnBody);
        } else {
            fnDeclaration = StringUtils.format(template.functionDeclaration.normal, fnName, passParams.join(", "), fnBody);
        }

        start = session.editor.posFromIndex(start);
        end   = session.editor.posFromIndex(end);

        // Get the insertion pos for function declaration
        var insertPos = _.clone(start);
        var fnScopes = scopes.filter(RefactoringUtils.isFnScope);

        for (var i = 0; i < fnScopes.length; ++i) {
            if (fnScopes[i].id === destScope.id) {
                if (fnScopes[i - 1]) {
                     insertPos = session.editor.posFromIndex(fnScopes[i - 1].originNode.start);
                     // If the origin node of the destination scope is a function expression or a arrow function expression,
                     // get the surrounding statement to get the position
                     if (fnScopes[i - 1].originNode.type === "FunctionExpression" || fnScopes[i - 1].originNode.type === "ArrowFunctionExpression") {
                         var surroundStatement = RefactoringUtils.findSurroundASTNode(ast, { start: session.editor.indexFromPos(insertPos)}, ["Statement"]);
                         insertPos = session.editor.posFromIndex(surroundStatement.start);
                     }
                }
                break;
            }
        }

        insertPos.ch = 0;

        // Replace and multi-select and indent
        doc.batchOperation(function() {
            // Replace
            doc.replaceRange(fnCall, start, end);
            doc.replaceRange(fnDeclaration, insertPos);

            // Set selections
            start = doc.adjustPosForChange(start, fnDeclaration.split("\n"), insertPos, insertPos);
            end   = doc.adjustPosForChange(end, fnDeclaration.split("\n"), insertPos, insertPos);

            session.editor.setSelections([
                {
                    start: session.editor.posFromIndex(session.editor.indexFromPos(start) + fnCall.indexOf(fnName)),
                    end:   session.editor.posFromIndex(session.editor.indexFromPos(start) + fnCall.indexOf(fnName) + fnName.length)
                },
                {
                    start: session.editor.posFromIndex(session.editor.indexFromPos(insertPos) + fnDeclaration.indexOf(fnName)),
                    end:   session.editor.posFromIndex(session.editor.indexFromPos(insertPos) + fnDeclaration.indexOf(fnName) + fnName.length)
                }
            ]);

            // indent
            for (var i = start.line; i < start.line + RefactoringUtils.numLines(fnCall); ++i) {
                session.editor._codeMirror.indentLine(i, "smart");
            }
            for (var i = insertPos.line; i < insertPos.line + RefactoringUtils.numLines(fnDeclaration); ++i) {
                session.editor._codeMirror.indentLine(i, "smart");
            }
        });
    }

    /**
     * Main function that handles extract to function
     */
    function handleExtractToFunction() {
        var editor = EditorManager.getActiveEditor();
        var result = new $.Deferred(); // used only for testing purpose

        if (editor.getSelections().length > 1) {
            editor.displayErrorMessageAtCursor(Strings.ERROR_EXTRACTTO_FUNCTION_MULTICURSORS);
            result.resolve(Strings.ERROR_EXTRACTTO_FUNCTION_MULTICURSORS);
            return;
        }
        initializeSession(editor);

        var selection = editor.getSelection(),
            doc       = editor.document,
            retObj    = RefactoringUtils.normalizeText(editor.getSelectedText(), editor.indexFromPos(selection.start), editor.indexFromPos(selection.end)),
            text      = retObj.text,
            start     = retObj.start,
            end       = retObj.end,
            ast,
            scopes,
            expns,
            inlineMenu;

        RefactoringUtils.getScopeData(session, editor.posFromIndex(start)).done(function(scope) {
            ast = RefactoringUtils.getAST(doc.getText());

            var isExpression = false;
            if (!RefactoringUtils.checkStatement(ast, start, end, doc.getText())) {
                isExpression = RefactoringUtils.getExpression(ast, start, end, doc.getText());
                if (!isExpression) {
                    editor.displayErrorMessageAtCursor(Strings.ERROR_EXTRACTTO_FUNCTION_NOT_VALID);
                    result.resolve(Strings.ERROR_EXTRACTTO_FUNCTION_NOT_VALID);
                    return;
                }
            }
            scopes = RefactoringUtils.getAllScopes(ast, scope, doc.getText());

            // if only one scope, extract without menu
            if (scopes.length === 1) {
                extract(ast, text, scopes, scopes[0], scopes[0], start, end, isExpression);
                result.resolve();
                return;
            }

            inlineMenu = new InlineMenu(editor, Strings.EXTRACTTO_FUNCTION_SELECT_SCOPE);

            inlineMenu.open(scopes.filter(RefactoringUtils.isFnScope));

            result.resolve(inlineMenu);

            inlineMenu.onSelect(function (scopeId) {
                extract(ast, text, scopes, scopes[0], scopes[scopeId], start, end, isExpression);
                inlineMenu.close();
            });

            inlineMenu.onClose(function(){
                inlineMenu.close();
            });
        }).fail(function() {
            editor.displayErrorMessageAtCursor(Strings.ERROR_TERN_FAILED);
            result.resolve(Strings.ERROR_TERN_FAILED);
        });

        return result.promise();
    }

    /**
     * Creates a new session from editor and stores it in session global variable
     */
    function initializeSession(editor) {
        session = new Session(editor);
    }

    exports.handleExtractToFunction = handleExtractToFunction;
});