adobe/brackets

View on GitHub
src/JSUtils/Session.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.
 *
 */

/*jslint regexp: true */

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

    var StringMatch     = require("utils/StringMatch"),
        TokenUtils      = require("utils/TokenUtils"),
        LanguageManager = require("language/LanguageManager"),
        HTMLUtils       = require("language/HTMLUtils"),
        HintUtils       = require("JSUtils/HintUtils"),
        ScopeManager    = require("JSUtils/ScopeManager"),
        Acorn           = require("node_modules/acorn/dist/acorn"),
        Acorn_Loose     = require("node_modules/acorn/dist/acorn_loose");

    /**
     * Session objects encapsulate state associated with a hinting session
     * and provide methods for updating and querying the session.
     *
     * @constructor
     * @param {Editor} editor - the editor context for the session
     */
    function Session(editor) {
        this.editor = editor;
        this.path = editor.document.file.fullPath;
        this.ternHints = [];
        this.ternGuesses = null;
        this.fnType = null;
        this.builtins = null;
    }

    /**
     *  Get the builtin libraries tern is using.
     *
     * @return {Array.<string>} - array of library names.
     * @private
     */
    Session.prototype._getBuiltins = function () {
        if (!this.builtins) {
            this.builtins = ScopeManager.getBuiltins();
            this.builtins.push("requirejs.js");     // consider these globals as well.
        }

        return this.builtins;
    };

    /**
     * Get the name of the file associated with the current session
     *
     * @return {string} - the full pathname of the file associated with the
     *      current session
     */
    Session.prototype.getPath = function () {
        return this.path;
    };

    /**
     * Get the current cursor position.
     *
     * @return {{line: number, ch: number}} - the current cursor position
     */
    Session.prototype.getCursor = function () {
        return this.editor.getCursorPos();
    };

    /**
     * Get the text of a line.
     *
     * @param {number} line - the line number
     * @return {string} - the text of the line
     */
    Session.prototype.getLine = function (line) {
        var doc = this.editor.document;
        return doc.getLine(line);
    };

    /**
     * Get the offset of the current cursor position
     *
     * @return {number} - the offset into the current document of the current
     *      cursor
     */
    Session.prototype.getOffset = function () {
        var cursor = this.getCursor();

        return this.getOffsetFromCursor(cursor);
    };

    /**
     * Get the offset of a cursor position
     *
     * @param {{line: number, ch: number}} the line/col info
     * @return {number} - the offset into the current document of the cursor
     */
    Session.prototype.getOffsetFromCursor = function (cursor) {
        return this.editor.indexFromPos(cursor);
    };

    /**
     * Get the token at the given cursor position, or at the current cursor
     * if none is given.
     *
     * @param {?{line: number, ch: number}} cursor - the cursor position
     *      at which to retrieve a token
     * @return {Object} - the CodeMirror token at the given cursor position
     */
    Session.prototype.getToken = function (cursor) {
        var cm = this.editor._codeMirror;

        if (cursor) {
            return TokenUtils.getTokenAt(cm, cursor);
        } else {
            return TokenUtils.getTokenAt(cm, this.getCursor());
        }
    };

    /**
     * Get the token after the one at the given cursor position
     *
     * @param {{line: number, ch: number}} cursor - cursor position before
     *      which a token should be retrieved
     * @return {Object} - the CodeMirror token after the one at the given
     *      cursor position
     */
    Session.prototype.getNextTokenOnLine = function (cursor) {
        cursor = this.getNextCursorOnLine(cursor);
        if (cursor) {
            return this.getToken(cursor);
        }

        return null;
    };

    /**
     * Get the next cursor position on the line, or null if there isn't one.
     *
     * @return {?{line: number, ch: number}} - the cursor position
     *      immediately following the current cursor position, or null if
     *      none exists.
     */
    Session.prototype.getNextCursorOnLine = function (cursor) {
        var doc     = this.editor.document,
            line    = doc.getLine(cursor.line);

        if (cursor.ch < line.length) {
            return {
                ch  : cursor.ch + 1,
                line: cursor.line
            };
        } else {
            return null;
        }
    };

    /**
     * Get the token before the one at the given cursor position
     *
     * @param {{line: number, ch: number}} cursor - cursor position after
     *      which a token should be retrieved
     * @return {Object} - the CodeMirror token before the one at the given
     *      cursor position
     */
    Session.prototype._getPreviousToken = function (cursor) {
        var token   = this.getToken(cursor),
            prev    = token,
            doc     = this.editor.document;

        do {
            if (prev.start < cursor.ch) {
                cursor.ch = prev.start;
            } else if (prev.start > 0) {
                cursor.ch = prev.start - 1;
            } else if (cursor.line > 0) {
                cursor.ch = doc.getLine(cursor.line - 1).length;
                cursor.line--;
            } else {
                break;
            }
            prev = this.getToken(cursor);
        } while (!/\S/.test(prev.string));

        return prev;
    };

    /**
     * Get the token after the one at the given cursor position
     *
     * @param {{line: number, ch: number}} cursor - cursor position after
     *      which a token should be retrieved
     * @param {boolean} skipWhitespace - true if this should skip over whitespace tokens
     * @return {Object} - the CodeMirror token after the one at the given
     *      cursor position
     */
    Session.prototype.getNextToken = function (cursor, skipWhitespace) {
        var token   = this.getToken(cursor),
            next    = token,
            doc     = this.editor.document;

        do {
            if (next.end > cursor.ch) {
                cursor.ch = next.end;
            } else if (next.end < doc.getLine(cursor.line).length) {
                cursor.ch = next.end + 1;
            } else if (doc.getLine(cursor.line + 1)) {
                cursor.ch = 0;
                cursor.line++;
            } else {
                next = null;
                break;
            }
            next = this.getToken(cursor);
        } while (skipWhitespace && !/\S/.test(next.string));

        return next;
    };

    /**
     * Calculate a query string relative to the current cursor position
     * and token. E.g., from a state "identi<cursor>er", the query string is
     * "identi".
     *
     * @return {string} - the query string for the current cursor position
     */
    Session.prototype.getQuery = function () {
        var cursor  = this.getCursor(),
            token   = this.getToken(cursor),
            query   = "",
            start   = cursor.ch,
            end     = start;

        if (token) {
            var line = this.getLine(cursor.line);
            while (start > 0) {
                if (HintUtils.maybeIdentifier(line[start - 1])) {
                    start--;
                } else {
                    break;
                }
            }

            query = line.substring(start, end);
        }

        return query;
    };

    /**
     * Find the context of a property lookup. For example, for a lookup
     * foo(bar, baz(quux)).prop, foo is the context.
     *
     * @param {{line: number, ch: number}} cursor - the cursor position
     *      at which context information is to be retrieved
     * @param {number=} depth - the current depth of the parenthesis stack, or
     *      undefined if the depth is 0.
     * @return {string} - the context for the property that was looked up
     */
    Session.prototype.getContext = function (cursor, depth) {
        var token = this.getToken(cursor);

        if (depth === undefined) {
            depth = 0;
        }

        if (token.string === ")") {
            this._getPreviousToken(cursor);
            return this.getContext(cursor, ++depth);
        } else if (token.string === "(") {
            this._getPreviousToken(cursor);
            return this.getContext(cursor, --depth);
        } else {
            if (depth > 0 || token.string === ".") {
                this._getPreviousToken(cursor);
                return this.getContext(cursor, depth);
            } else {
                return token.string;
            }
        }
    };

    /**
     * @return {{line:number, ch:number}} - the line, col info for where the previous "."
     *      in a property lookup occurred, or undefined if no previous "." was found.
     */
    Session.prototype.findPreviousDot = function () {
        var cursor = this.getCursor(),
            token = this.getToken(cursor);

        // If the cursor is right after the dot, then the current token will be "."
        if (token && token.string === ".") {
            return cursor;
        } else {
            // If something has been typed like 'foo.b' then we have to look back 2 tokens
            // to get past the 'b' token
            token = this._getPreviousToken(cursor);
            if (token && token.string === ".") {
                return cursor;
            }
        }
        return undefined;
    };

    /**
     *
     * @param {Object} token - a CodeMirror token
     * @return {*} - the lexical state of the token
     */
    function getLexicalState(token) {
        if (token.state.lexical) {
            // in a javascript file this is just in the state field
            return token.state.lexical;
        } else if (token.state.localState && token.state.localState.lexical) {
            // inline javascript in an html file will have this in
            // the localState field
            return token.state.localState.lexical;
        }
    }


    /**
     * Determine if the caret is either within a function call or on the function call itself.
     *
     * @return {{inFunctionCall: boolean, functionCallPos: {line: number, ch: number}}}
     * inFunctionCall - true if the caret if either within a function call or on the
     * function call itself.
     * functionCallPos - the offset of the '(' character of the function call if inFunctionCall
     * is true, otherwise undefined.
     */
    Session.prototype.getFunctionInfo = function () {
        var inFunctionCall   = false,
            cursor           = this.getCursor(),
            functionCallPos,
            token            = this.getToken(cursor),
            lexical,
            self = this,
            foundCall = false;

        /**
         * Test if the cursor is on a function identifier
         *
         * @return {Object} - lexical state if on a function identifier, null otherwise.
         */
        function isOnFunctionIdentifier() {

            // Check if we might be on function identifier of the function call.
            var type = token.type,
                nextToken,
                localLexical,
                localCursor = {line: cursor.line, ch: token.end};

            if (type === "variable-2" || type === "variable" || type === "property") {
                nextToken = self.getNextToken(localCursor, true);
                if (nextToken && nextToken.string === "(") {
                    localLexical = getLexicalState(nextToken);
                    return localLexical;
                }
            }

            return null;
        }

        /**
         * Test is a lexical state is in a function call.
         *
         * @param {Object} lex - lexical state.
         * @return {Object | boolean}
         *
         */
        function isInFunctionalCall(lex) {
            // in a call, or inside array or object brackets that are inside a function.
            return (lex && (lex.info === "call" ||
                (lex.info === undefined && (lex.type === "]" || lex.type === "}") &&
                    lex.prev.info === "call")));
        }

        if (token) {
            // if this token is part of a function call, then the tokens lexical info
            // will be annotated with "call".
            // If the cursor is inside an array, "[]", or object, "{}", the lexical state
            // will be undefined, not "call". lexical.prev will be the function state.
            // Handle this case and then set "lexical" to lexical.prev.
            // Also test if the cursor is on a function identifier of a function call.
            lexical = getLexicalState(token);
            foundCall = isInFunctionalCall(lexical);

            if (!foundCall) {
                lexical = isOnFunctionIdentifier();
                foundCall = isInFunctionalCall(lexical);
            }

            if (foundCall) {
                // we need to find the location of the called function so that we can request the functions type.
                // the token's lexical info will contain the column where the open "(" for the
                // function call occurs, but for whatever reason it does not have the line, so
                // we have to walk back and try to find the correct location.  We do this by walking
                // up the lines starting with the line the token is on, and seeing if any of the lines
                // have "(" at the column indicated by the tokens lexical state.
                // We walk back 9 lines, as that should be far enough to find most function calls,
                // and it will prevent us from walking back thousands of lines if something went wrong.
                // there is nothing magical about 9 lines, and it can be adjusted if it doesn't seem to be
                // working well
                if (lexical.info === undefined) {
                    lexical = lexical.prev;
                }

                var col = lexical.info === "call" ? lexical.column : lexical.prev.column,
                    line,
                    e,
                    found;
                for (line = this.getCursor().line, e = Math.max(0, line - 9), found = false; line >= e; --line) {
                    if (this.getLine(line).charAt(col) === "(") {
                        found = true;
                        break;
                    }
                }

                if (found) {
                    inFunctionCall = true;
                    functionCallPos = {line: line, ch: col};
                }
            }
        }

        return {
            inFunctionCall: inFunctionCall,
            functionCallPos: functionCallPos
        };
    };

    /**
     * Get the type of the current session, i.e., whether it is a property
     * lookup and, if so, what the context of the lookup is.
     *
     * @return {{property: boolean,
                 context: string} - an Object consisting
     *      of a {boolean} "property" that indicates whether or not the type of
     *      the session is a property lookup, and a {string} "context" that
     *      indicates the object context (as described in getContext above) of
     *      the property lookup, or null if there is none. The context is
     *      always null for non-property lookups.
     */
    Session.prototype.getType = function () {
        var propertyLookup   = false,
            context          = null,
            cursor           = this.getCursor(),
            token            = this.getToken(cursor);

        if (token) {
            if (token.type === "property") {
                propertyLookup = true;
            }

            cursor = this.findPreviousDot();
            if (cursor) {
                propertyLookup = true;
                context = this.getContext(cursor);
            }
        }

        return {
            property: propertyLookup,
            context: context
        };
    };

    // Comparison function used for sorting that does a case-insensitive string
    // comparison on the "value" field of both objects. Unlike a normal string
    // comparison, however, this sorts leading "_" to the bottom, given that a
    // leading "_" usually denotes a private value.
    function penalizeUnderscoreValueCompare(a, b) {
        var aName = a.value.toLowerCase(), bName = b.value.toLowerCase();
        // this sort function will cause _ to sort lower than lower case
        // alphabetical letters
        if (aName[0] === "_" && bName[0] !== "_") {
            return 1;
        } else if (bName[0] === "_" && aName[0] !== "_") {
            return -1;
        }
        if (aName < bName) {
            return -1;
        } else if (aName > bName) {
            return 1;
        }
        return 0;
    }

    /**
     * Get a list of hints for the current session using the current scope
     * information.
     *
     * @param {string} query - the query prefix
     * @param {StringMatcher} matcher - the class to find query matches and sort the results
     * @return {hints: Array.<string>, needGuesses: boolean} - array of
     * matching hints. If needGuesses is true, then the caller needs to
     * request guesses and call getHints again.
     */
    Session.prototype.getHints = function (query, matcher) {

        if (query === undefined) {
            query = "";
        }

        var MAX_DISPLAYED_HINTS = 500,
            type                = this.getType(),
            builtins            = this._getBuiltins(),
            needGuesses         = false,
            hints;

        /**
         *  Is the origin one of the builtin files.
         *
         * @param {string} origin
         */
        function isBuiltin(origin) {
            return builtins.indexOf(origin) !== -1;
        }

        /**
         *  Filter an array hints using a given query and matcher.
         *  The hints are returned in the format of the matcher.
         *  The matcher returns the value in the "label" property,
         *  the match score in "matchGoodness" property.
         *
         * @param {Array} hints - array of hints
         * @param {StringMatcher} matcher
         * @return {Array} - array of matching hints.
         */
        function filterWithQueryAndMatcher(hints, matcher) {
            var matchResults = $.map(hints, function (hint) {
                var searchResult = matcher.match(hint.value, query);
                if (searchResult) {
                    searchResult.value = hint.value;
                    searchResult.guess = hint.guess;
                    searchResult.type = hint.type;

                    if (hint.keyword !== undefined) {
                        searchResult.keyword = hint.keyword;
                    }

                    if (hint.literal !== undefined) {
                        searchResult.literal = hint.literal;
                    }

                    if (hint.depth !== undefined) {
                        searchResult.depth = hint.depth;
                    }

                    if (hint.doc) {
                        searchResult.doc = hint.doc;
                    }

                    if (hint.url) {
                        searchResult.url = hint.url;
                    }

                    if (!type.property && !type.showFunctionType && hint.origin &&
                            isBuiltin(hint.origin)) {
                        searchResult.builtin = 1;
                    } else {
                        searchResult.builtin = 0;
                    }
                }

                return searchResult;
            });

            return matchResults;
        }

        if (type.property) {
            hints = this.ternHints || [];
            hints = filterWithQueryAndMatcher(hints, matcher);

            // If there are no hints then switch over to guesses.
            if (hints.length === 0) {
                if (this.ternGuesses) {
                    hints = filterWithQueryAndMatcher(this.ternGuesses, matcher);
                } else {
                    needGuesses = true;
                }
            }

            StringMatch.multiFieldSort(hints, [ "matchGoodness", penalizeUnderscoreValueCompare ]);
        } else {     // identifiers, literals, and keywords
            hints = this.ternHints || [];
            hints = hints.concat(HintUtils.LITERALS);
            hints = hints.concat(HintUtils.KEYWORDS);
            hints = filterWithQueryAndMatcher(hints, matcher);
            StringMatch.multiFieldSort(hints, [ "matchGoodness", "depth", "builtin", penalizeUnderscoreValueCompare ]);
        }

        if (hints.length > MAX_DISPLAYED_HINTS) {
            hints = hints.slice(0, MAX_DISPLAYED_HINTS);
        }

        return {hints: hints, needGuesses: needGuesses};
    };

    Session.prototype.setTernHints = function (newHints) {
        this.ternHints = newHints;
    };

    Session.prototype.setGuesses = function (newGuesses) {
        this.ternGuesses = newGuesses;
    };

    /**
     * Set a new function type hint.
     *
     * @param {Array<{name: string, type: string, isOptional: boolean}>} newFnType -
     * Array of function hints.
     */
    Session.prototype.setFnType = function (newFnType) {
        this.fnType = newFnType;
    };

    /**
     * The position of the function call for the current fnType.
     *
     * @param {{line:number, ch:number}} functionCallPos - the offset of the function call.
     */
    Session.prototype.setFunctionCallPos = function (functionCallPos) {
        this.functionCallPos = functionCallPos;
    };

    /**
     * Get the function type hint.  This will format the hint, showing the
     * parameter at the cursor in bold.
     *
     * @return {{parameters: Array<{name: string, type: string, isOptional: boolean}>,
     * currentIndex: number}} An Object where the
     * "parameters" property is an array of parameter objects;
     * the "currentIndex" property index of the hint the cursor is on, may be
     * -1 if the cursor is on the function identifier.
     */
    Session.prototype.getParameterHint = function () {
        var fnHint = this.fnType,
            cursor = this.getCursor(),
            token = this.getToken(this.functionCallPos),
            start = {line: this.functionCallPos.line, ch: token.start},
            fragment = this.editor.document.getRange(start,
                {line: this.functionCallPos.line + 10, ch: 0});

        var ast;
        try {
            ast = Acorn.parse(fragment);
        } catch (e) {
            ast = Acorn_Loose.parse_dammit(fragment, {});
        }

        // find argument as cursor location and bold it.
        var startOffset = this.getOffsetFromCursor(start),
            cursorOffset = this.getOffsetFromCursor(cursor),
            offset = cursorOffset - startOffset,
            node = ast.body[0],
            currentArg = -1;

        if (node.type === "ExpressionStatement") {
            node = node.expression;
            if (node.type === "SequenceExpression") {
                node = node.expressions[0];
            }
            if (node.type === "BinaryExpression") {
                if (node.left.type === "CallExpression") {
                    node = node.left;
                } else if (node.right.type === "CallExpression") {
                    node = node.right;
                }
            }
            if (node.type === "CallExpression") {
                var args = node["arguments"],
                    i,
                    n = args.length,
                    lastEnd = offset,
                    text;
                for (i = 0; i < n; i++) {
                    node = args[i];
                    if (offset >= node.start && offset <= node.end) {
                        currentArg = i;
                        break;
                    } else if (offset < node.start) {
                        // The range of nodes can be disjoint so see i f we
                        // passed the node. If we passed the node look at the
                        // text between the nodes to figure out which
                        // arg we are on.
                        text = fragment.substring(lastEnd, node.start);

                        // test if comma is before or after the offset
                        if (text.indexOf(",") >= (offset - lastEnd)) {
                            // comma is after the offset so the current arg is the
                            // previous arg node.
                            i--;
                        } else if (i === 0 && text.indexOf("(") !== -1) {
                            // the cursor is on the function identifier
                            currentArg = -1;
                            break;
                        }

                        currentArg = Math.max(0, i);
                        break;
                    } else if (i + 1 === n) {
                        // look for a comma after the node.end. This will tell us we
                        // are on the next argument, even there is no text, and therefore no node,
                        // for the next argument.
                        text = fragment.substring(node.end, offset);
                        if (text.indexOf(",") !== -1) {
                            currentArg = i + 1; // we know we are after the current arg, but keep looking
                        }
                    }

                    lastEnd = node.end;
                }

                // if there are no args, then figure out if we are on the function identifier
                if (n === 0 && cursorOffset > this.getOffsetFromCursor(this.functionCallPos)) {
                    currentArg = 0;
                }
            }
        }

        return {parameters: fnHint, currentIndex: currentArg};
    };

    /**
     * Get the javascript text of the file open in the editor for this Session.
     * For a javascript file, this is just the text of the file.  For an HTML file,
     * this will be only the text in the <script> tags.  This is so that we can pass
     * just the javascript text to tern, and avoid confusing it with HTML tags, since it
     * only knows how to parse javascript.
     * @return {string} - the "javascript" text that can be sent to Tern.
     */
    Session.prototype.getJavascriptText = function () {
        if (LanguageManager.getLanguageForPath(this.editor.document.file.fullPath).getId() === "html") {
            // HTML file - need to send back only the bodies of the
            // <script> tags
            var text = "",
                editor = this.editor,
                scriptBlocks = HTMLUtils.findBlocks(editor, "javascript");

            // Add all the javascript text
            // For non-javascript blocks we replace everything except for newlines
            // with whitespace.  This is so that the offset and cursor positions
            // we get from the document still work.
            // Alternatively we could strip the non-javascript text, and modify the offset,
            // and/or cursor, but then we have to remember how to reverse the translation
            // to support jump-to-definition
            var htmlStart = {line: 0, ch: 0};
            scriptBlocks.forEach(function (scriptBlock) {
                var start = scriptBlock.start,
                    end = scriptBlock.end;

                // get the preceding html text, and replace it with whitespace
                var htmlText = editor.document.getRange(htmlStart, start);
                htmlText = htmlText.replace(/./g, " ");

                htmlStart = end;
                text += htmlText + scriptBlock.text;
            });

            return text;
        } else {
            // Javascript file, just return the text
            return this.editor.document.getText();
        }
    };

    /**
     * Determine if the cursor is located in the name of a function declaration.
     * This is so we can suppress hints when in a function name, as we do for variable and
     * parameter declarations, but we can tell those from the token itself rather than having
     * to look at previous tokens.
     *
     * @return {boolean} - true if the current cursor position is in the name of a function
     * declaration.
     */
    Session.prototype.isFunctionName = function () {
        var cursor = this.getCursor(),
            prevToken = this._getPreviousToken(cursor);

        return prevToken.string === "function";
    };

    module.exports = Session;
});