src/JSUtils/Session.js
/*
* 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;
});