diff --git a/src/command/CommandManager.js b/src/command/CommandManager.js index 8592c25916b..a26694194e4 100644 --- a/src/command/CommandManager.js +++ b/src/command/CommandManager.js @@ -237,7 +237,7 @@ define(function (require, exports, module) { if (command) { try { - $(exports).triggerHandler("beforeExecuteCommand"); + $(exports).triggerHandler("beforeExecuteCommand", id); } catch (err) { console.error(err); } diff --git a/src/extensions/default/JavaScriptCodeHints/HintUtils.js b/src/extensions/default/JavaScriptCodeHints/HintUtils.js index 43c2a4a6137..6de4c8314e0 100644 --- a/src/extensions/default/JavaScriptCodeHints/HintUtils.js +++ b/src/extensions/default/JavaScriptCodeHints/HintUtils.js @@ -27,7 +27,7 @@ define(function (require, exports, module) { "use strict"; - var acorn = require("thirdparty/acorn/acorn"); + var Acorn = require("thirdparty/acorn/acorn"); var LANGUAGE_ID = "javascript", HTML_LANGUAGE_ID = "html", @@ -64,7 +64,7 @@ define(function (require, exports, module) { i; for (i = 0; i < key.length; i++) { - result = acorn.isIdentifierChar(key.charCodeAt(i)); + result = Acorn.isIdentifierChar(key.charCodeAt(i)); if (!result) { break; } diff --git a/src/extensions/default/JavaScriptCodeHints/HintUtils2.js b/src/extensions/default/JavaScriptCodeHints/HintUtils2.js new file mode 100644 index 00000000000..9b58f43283e --- /dev/null +++ b/src/extensions/default/JavaScriptCodeHints/HintUtils2.js @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2013 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 vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50, regexp: true */ +/*global define */ + +/** + * HintUtils2 was created as a place to put utilities that do not require third party dependencies so + * they can be used by tern-worker.js and other JS files. + * This is done because of the require config in tern-worker.js needed to load tern libraries. Libraries + * that include, say "acorn", will fail to load. + */ +define(function (require, exports, module) { + "use strict"; + + /** + * Format the given parameter array. Handles separators between + * parameters, syntax for optional parameters, and the order of the + * parameter type and parameter name. + * + * @param {!Array.<{name: string, type: string, isOptional: boolean}>} params - + * array of parameter descriptors + * @param {function(string)=} appendSeparators - callback function to append separators. + * The separator is passed to the callback. + * @param {function(string, number)=} appendParameter - callback function to append parameter. + * The formatted parameter type and name is passed to the callback along with the + * current index of the parameter. + * @param {boolean=} typesOnly - only show parameter types. The + * default behavior is to include both parameter names and types. + * @return {string} - formatted parameter hint + */ + function formatParameterHint(params, appendSeparators, appendParameter, typesOnly) { + var result = "", + pendingOptional = false; + + params.forEach(function (value, i) { + var param = value.type, + separators = ""; + + if (value.isOptional) { + // if an optional param is following by an optional parameter, then + // terminate the bracket. Otherwise enclose a required parameter + // in the same bracket. + if (pendingOptional) { + separators += "]"; + } + + pendingOptional = true; + } + + if (i > 0) { + separators += ", "; + } + + if (value.isOptional) { + separators += "["; + } + + if (appendSeparators) { + appendSeparators(separators); + } + + result += separators; + + if (!typesOnly) { + param += " " + value.name; + } + + if (appendParameter) { + appendParameter(param, i); + } + + result += param; + + }); + + if (pendingOptional) { + if (appendSeparators) { + appendSeparators("]"); + } + + result += "]"; + } + + return result; + } + + exports.formatParameterHint = formatParameterHint; +}); \ No newline at end of file diff --git a/src/extensions/default/JavaScriptCodeHints/ParameterHintManager.js b/src/extensions/default/JavaScriptCodeHints/ParameterHintManager.js new file mode 100644 index 00000000000..0c9654b6c98 --- /dev/null +++ b/src/extensions/default/JavaScriptCodeHints/ParameterHintManager.js @@ -0,0 +1,418 @@ +/* + * Copyright (c) 2013 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 vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50, regexp: true */ +/*global define, brackets, $ */ + +define(function (require, exports, module) { + "use strict"; + + var Commands = brackets.getModule("command/Commands"), + CommandManager = brackets.getModule("command/CommandManager"), + KeyEvent = brackets.getModule("utils/KeyEvent"), + Menus = brackets.getModule("command/Menus"), + Strings = brackets.getModule("strings"), + StringUtils = brackets.getModule("utils/StringUtils"), + HintsUtils2 = require("HintUtils2"), + ScopeManager = require("ScopeManager"), + Session = require("Session"); + + + /** @const {string} Show Function Hint command ID */ + var SHOW_PARAMETER_HINT_CMD_ID = "showParameterHint", // string must MATCH string in native code (brackets_extensions) + PUSH_EXISTING_HINT = true, + OVERWRITE_EXISTING_HINT = false, + PRESERVE_FUNCTION_STACK = true, + hintContainerHTML = require("text!ParameterHintTemplate.html"), + KeyboardPrefs = JSON.parse(require("text!keyboard.json")); + + var $hintContainer, // function hint container + $hintContent, // function hint content holder + + /** @type {{inFunctionCall: boolean, functionCallPos: {line: number, ch: number}, + * fnType: Array.") + .append(StringUtils.htmlEscape(param)) + .addClass("current-parameter")); + } else { + $hintContent.append(StringUtils.htmlEscape(param)); + } + } + + if (hints.parameters.length > 0) { + HintsUtils2.formatParameterHint(hints.parameters, appendSeparators, appendParameter); + } else { + $hintContent.append(StringUtils.htmlEscape(Strings.NO_ARGUMENTS)); + } + } + + /** + * Save the state of the current hint. Called when popping up a parameter hint + * for a parameter, when the parameter already part of an existing parameter + * hint. + */ + function pushHintOnStack() { + hintStack.push(hintState); + } + + /** + * Restore the state of the previous function hint. + * + * @return {boolean} - true the a parameter hint has been popped, false otherwise. + */ + function popHintFromStack() { + if (hintStack.length > 0) { + hintState = hintStack.pop(); + hintState.visible = false; + return true; + } + + return false; + } + + /** + * Reset the function hint stack. + */ + function clearFunctionHintStack() { + hintStack = []; + } + + /** + * Test if the function call at the cursor is different from the currently displayed + * function hint. + * + * @param functionCallPos + * @return {boolean} + */ + function hasFunctionCallPosChanged(functionCallPos) { + var oldFunctionCallPos = hintState.functionCallPos; + return (oldFunctionCallPos === undefined || + oldFunctionCallPos.line !== functionCallPos.line || + oldFunctionCallPos.ch !== functionCallPos.ch); + } + + /** + * Dismiss the function hint. + * + */ + function dismissHint() { + + if (hintState.visible) { + $hintContainer.hide(); + $hintContent.empty(); + hintState = {}; + $(session.editor).off("cursorActivity", handleCursorActivity); + + if (!preserveHintStack) { + clearFunctionHintStack(); + } + } + } + + /** + * Pop up a function hint on the line above the caret position. + * + * @param {boolean=} pushExistingHint - if true, push the existing hint on the stack. Default is false, not + * to push the hint. + * @param {string=} hint - function hint string from tern. + * @param {{inFunctionCall: boolean, functionCallPos: + * {line: number, ch: number}}=} functionInfo - + * if the functionInfo is already known, it can be passed in to avoid + * figuring it out again. + * @return {jQuery.Promise} - The promise will not complete until the + * hint has completed. Returns null, if the function hint is already + * displayed or the there is no function hint at the cursor. + * + */ + function popUpHint(pushExistingHint, hint, functionInfo) { + + functionInfo = functionInfo || session.getFunctionInfo(); + if (!functionInfo.inFunctionCall) { + dismissHint(); + return null; + } + + if (hasFunctionCallPosChanged(functionInfo.functionCallPos)) { + + var pushHint = pushExistingHint && isHintDisplayed(); + if (pushHint) { + pushHintOnStack(); + preserveHintStack = true; + } + + dismissHint(); + preserveHintStack = false; + } else if (isHintDisplayed()) { + return null; + } + + hintState.functionCallPos = functionInfo.functionCallPos; + + var request = null; + var $deferredPopUp = $.Deferred(); + + if (!hint) { + request = ScopeManager.requestParameterHint(session, functionInfo.functionCallPos); + } else { + session.setFnType(hint); + request = $.Deferred(); + request.resolveWith(null, [hint]); + $deferredPopUp.resolveWith(null); + } + + request.done(function (fnType) { + var cm = session.editor._codeMirror, + pos = cm.charCoords(functionInfo.functionCallPos); + + formatHint(functionInfo); + + $hintContainer.show(); + positionHint(pos.left, pos.top, pos.bottom); + hintState.visible = true; + hintState.fnType = fnType; + + $(session.editor).on("cursorActivity", handleCursorActivity); + $deferredPopUp.resolveWith(null); + }).fail(function () { + hintState = {}; + }); + + return $deferredPopUp; + } + + /** + * Show the parameter the cursor is on in bold when the cursor moves. + * Dismiss the pop up when the cursor moves off the function. + */ + handleCursorActivity = function () { + var functionInfo = session.getFunctionInfo(); + + if (functionInfo.inFunctionCall) { + // If in a different function hint, then dismiss the old one a + // display the new one if there is one on the stack + if (hasFunctionCallPosChanged(functionInfo.functionCallPos)) { + if (popHintFromStack()) { + var poppedFunctionCallPos = hintState.functionCallPos, + currentFunctionCallPos = functionInfo.functionCallPos; + + if (poppedFunctionCallPos.line === currentFunctionCallPos.line && + poppedFunctionCallPos.ch === currentFunctionCallPos.ch) { + preserveHintStack = true; + popUpHint(OVERWRITE_EXISTING_HINT, + hintState.fnType, functionInfo); + preserveHintStack = false; + return; + } + } else { + dismissHint(); + } + } + + formatHint(functionInfo); + return; + } + + dismissHint(); + }; + + /** + * Enable cursor tracking in the current session. + * + * @param {Session} session - session to stop cursor tracking on. + */ + function startCursorTracking(session) { + $(session.editor).on("cursorActivity", handleCursorActivity); + } + + /** + * Stop cursor tracking in the current session. + * + * Use this to move the cursor without changing the function hint state. + * + * @param {Session} session - session to stop cursor tracking on. + */ + function stopCursorTracking(session) { + $(session.editor).off("cursorActivity", handleCursorActivity); + } + + /** + * Show a parameter hint in its own pop-up. + * + */ + function handleShowParameterHint() { + + // Pop up function hint + popUpHint(); + } + + /** + * Install function hint listeners. + * + * @param {Editor} editor - editor context on which to listen for + * changes + */ + function installListeners(editor) { + + $(editor).on("keyEvent", function (jqEvent, editor, event) { + if (event.type === "keydown" && event.keyCode === KeyEvent.DOM_VK_ESCAPE) { + dismissHint(); + } + }).on("scroll", function () { + dismissHint(); + }); + } + + /** + * Add the function hint command at start up. + */ + function addCommands() { + /* Register the command handler */ + CommandManager.register(Strings.CMD_SHOW_PARAMETER_HINT, SHOW_PARAMETER_HINT_CMD_ID, handleShowParameterHint); + + // Add the menu items + var menu = Menus.getMenu(Menus.AppMenuBar.EDIT_MENU); + if (menu) { + menu.addMenuItem(SHOW_PARAMETER_HINT_CMD_ID, KeyboardPrefs.showParameterHint, Menus.AFTER, Commands.SHOW_CODE_HINTS); + } + + // Close the function hint when commands are executed, except for the commands + // to show function hints for code hints. + $(CommandManager).on("beforeExecuteCommand", function (jqEvent, commandId) { + if (commandId !== SHOW_PARAMETER_HINT_CMD_ID && + commandId !== Commands.SHOW_CODE_HINTS) { + dismissHint(); + } + }); + } + + // Create the function hint container + $hintContainer = $(hintContainerHTML).appendTo($("body")); + $hintContent = $hintContainer.find(".function-hint-content"); + + exports.PUSH_EXISTING_HINT = PUSH_EXISTING_HINT; + exports.addCommands = addCommands; + exports.dismissHint = dismissHint; + exports.installListeners = installListeners; + exports.isHintDisplayed = isHintDisplayed; + exports.popUpHint = popUpHint; + exports.setSession = setSession; + exports.startCursorTracking = startCursorTracking; + exports.stopCursorTracking = stopCursorTracking; + +}); \ No newline at end of file diff --git a/src/extensions/default/JavaScriptCodeHints/ParameterHintTemplate.html b/src/extensions/default/JavaScriptCodeHints/ParameterHintTemplate.html new file mode 100644 index 00000000000..04f8a9c04a2 --- /dev/null +++ b/src/extensions/default/JavaScriptCodeHints/ParameterHintTemplate.html @@ -0,0 +1,4 @@ +
+
+
+
diff --git a/src/extensions/default/JavaScriptCodeHints/Preferences.js b/src/extensions/default/JavaScriptCodeHints/Preferences.js index fb5da63b3cc..b37a3bea166 100644 --- a/src/extensions/default/JavaScriptCodeHints/Preferences.js +++ b/src/extensions/default/JavaScriptCodeHints/Preferences.js @@ -172,7 +172,7 @@ define(function (require, exports, module) { /** * Get the regular expression for excluded directories. * - * @returns {?RegExp} Regular expression matching the directories that should + * @return {?RegExp} Regular expression matching the directories that should * be excluded. Returns null if no directories are excluded. */ Preferences.prototype.getExcludedDirectories = function () { @@ -182,7 +182,7 @@ define(function (require, exports, module) { /** * Get the regular expression for excluded files. * - * @returns {?RegExp} Regular expression matching the files that should + * @return {?RegExp} Regular expression matching the files that should * be excluded. Returns null if no files are excluded. */ Preferences.prototype.getExcludedFiles = function () { @@ -192,7 +192,7 @@ define(function (require, exports, module) { /** * Get the maximum number of files that will be analyzed. * - * @returns {number} + * @return {number} */ Preferences.prototype.getMaxFileCount = function () { return this._maxFileCount; @@ -202,7 +202,7 @@ define(function (require, exports, module) { * Get the maximum size of a file that will be analyzed. Files that are * larger will be ignored. * - * @returns {number} + * @return {number} */ Preferences.prototype.getMaxFileSize = function () { return this._maxFileSize; diff --git a/src/extensions/default/JavaScriptCodeHints/ScopeManager.js b/src/extensions/default/JavaScriptCodeHints/ScopeManager.js index c344a1fcca7..b1fd6802dbb 100644 --- a/src/extensions/default/JavaScriptCodeHints/ScopeManager.js +++ b/src/extensions/default/JavaScriptCodeHints/ScopeManager.js @@ -65,7 +65,7 @@ define(function (require, exports, module) { /** * An array of library names that contain JavaScript builtins definitions. * - * @returns {Array.} - array of library names. + * @return {Array.} - array of library names. */ function getBuiltins() { return builtinLibraryNames; @@ -228,7 +228,7 @@ define(function (require, exports, module) { * Test if the directory should be excluded from analysis. * * @param {!string} path - full directory path. - * @returns {boolean} true if excluded, false otherwise. + * @return {boolean} true if excluded, false otherwise. */ function isDirectoryExcluded(path) { var excludes = preferences.getExcludedDirectories(); @@ -247,7 +247,7 @@ define(function (require, exports, module) { * Test if the file should be excluded from analysis. * * @param {!string} path - full directory path. - * @returns {boolean} true if excluded, false otherwise. + * @return {boolean} true if excluded, false otherwise. */ function isFileExcluded(path) { var excludes = preferences.getExcludedFiles(); @@ -497,7 +497,7 @@ define(function (require, exports, module) { * * @param {!Session} session - the current session * @param {{line: number, ch: number}} start - the starting position of the changes - * @returns {{type: string, name: string, offsetLines: number, text: string}} + * @return {{type: string, name: string, offsetLines: number, text: string}} */ function getFragmentAround(session, start) { var minIndent = null, @@ -566,9 +566,11 @@ define(function (require, exports, module) { * changes are reset. * * @param {!Session} session - the current session - * @returns {{type: string, name: {string}, offsetLines: {number}, text: {string}} + * @param {boolean=} preventPartialUpdates - if true, disallow partial updates. + * Optional, defaults to false. + * @return {{type: string, name: string, offsetLines: number, text: string}} */ - function getFileInfo(session) { + function getFileInfo(session, preventPartialUpdates) { var start = session.getCursor(), end = start, document = session.editor.document, @@ -584,7 +586,7 @@ define(function (require, exports, module) { result = {type: MessageIds.TERN_FILE_INFO_TYPE_EMPTY, name: path, text: ""}; - } else if (session.editor.lineCount() > LARGE_LINE_COUNT && + } else if (!preventPartialUpdates && session.editor.lineCount() > LARGE_LINE_COUNT && (documentChanges.to - documentChanges.from < LARGE_LINE_CHANGE) && documentChanges.from <= start.line && documentChanges.to > end.line) { @@ -610,10 +612,16 @@ define(function (require, exports, module) { * and the text is empty. * @param {{line: number, ch: number}=} offset - the default offset (optional). Will * use the cursor if not provided. - * @returns {{line: number, ch: number}} + * @return {{line: number, ch: number}} */ function getOffset(session, fileInfo, offset) { - var newOffset = offset || session.getCursor(); + var newOffset; + + if (offset) { + newOffset = {line: offset.line, ch: offset.ch}; + } else { + newOffset = session.getCursor(); + } if (fileInfo.type === MessageIds.TERN_FILE_INFO_TYPE_PART) { newOffset.line = Math.max(0, newOffset.line - fileInfo.offsetLines); @@ -668,10 +676,13 @@ define(function (require, exports, module) { properties = response.properties, fnType = response.fnType, type = response.type, + error = response.error, $deferredHints = getPendingRequest(file, offset, type); if ($deferredHints) { - if (completions) { + if (error) { + $deferredHints.reject(); + } else if (completions) { $deferredHints.resolveWith(null, [{completions: completions}]); } else if (properties) { $deferredHints.resolveWith(null, [{properties: properties}]); @@ -748,7 +759,7 @@ define(function (require, exports, module) { * Determine whether the current set of files are using modules to pull in * additional files. * - * @returns {boolean} - true if more files than the current directory have + * @return {boolean} - true if more files than the current directory have * been read in. */ function usingModules() { @@ -1088,7 +1099,7 @@ define(function (require, exports, module) { * already been added to tern. * * @param {string} newFile - full path of new file being opened in the editor. - * @returns {boolean} - true if tern initialization should be skipped, + * @return {boolean} - true if tern initialization should be skipped, * false otherwise. */ function canSkipTernInitialization(newFile) { @@ -1267,6 +1278,33 @@ define(function (require, exports, module) { } } + /** + * Request a parameter hint from Tern. + * + * @param {Session} session - the active hinting session + * @param {{line: number, ch: number}} functionOffset - the offset the function call. + * @return {jQuery.Promise} - The promise will not complete until the + * hint has completed. + */ + function requestParameterHint(session, functionOffset) { + var $deferredHints = $.Deferred(), + fileInfo = getFileInfo(session, true), + offset = getOffset(session, fileInfo, functionOffset), + fnTypePromise = getTernFunctionType(fileInfo, offset); + + $.when(fnTypePromise).done( + function (fnType) { + session.setFnType(fnType); + session.setFunctionCallPos(functionOffset); + $deferredHints.resolveWith(null, [fnType]); + } + ).fail(function () { + $deferredHints.reject(); + }); + + return $deferredHints.promise(); + } + /** * Request hints from Tern. * @@ -1284,26 +1322,15 @@ define(function (require, exports, module) { function requestHints(session, document) { var $deferredHints = $.Deferred(), hintPromise, - fnTypePromise, sessionType = session.getType(), fileInfo = getFileInfo(session), - offset = getOffset(session, fileInfo, - sessionType.showFunctionType ? sessionType.functionCallPos : null); + offset = getOffset(session, fileInfo, null); maybeReset(session, document); hintPromise = getTernHints(fileInfo, offset, sessionType.property); - if (sessionType.showFunctionType) { - // Show function sig - fnTypePromise = getTernFunctionType(fileInfo, offset); - } else { - var $fnTypeDeferred = $.Deferred(); - fnTypePromise = $fnTypeDeferred.promise(); - $fnTypeDeferred.resolveWith(null); - } - - $.when(hintPromise, fnTypePromise).done( + $.when(hintPromise).done( function (completions, fnType) { if (completions.completions) { session.setTernHints(completions.completions); @@ -1313,7 +1340,6 @@ define(function (require, exports, module) { session.setGuesses(completions.properties); } - session.setFnType(fnType); $deferredHints.resolveWith(null); } ).fail(function () { @@ -1408,6 +1434,7 @@ define(function (require, exports, module) { exports.handleFileChange = handleFileChange; exports.requestHints = requestHints; exports.requestJumptoDef = requestJumptoDef; + exports.requestParameterHint = requestParameterHint; exports.handleProjectClose = handleProjectClose; exports.handleProjectOpen = handleProjectOpen; diff --git a/src/extensions/default/JavaScriptCodeHints/Session.js b/src/extensions/default/JavaScriptCodeHints/Session.js index d45a009b1c9..5534cbdcc7e 100644 --- a/src/extensions/default/JavaScriptCodeHints/Session.js +++ b/src/extensions/default/JavaScriptCodeHints/Session.js @@ -32,7 +32,9 @@ define(function (require, exports, module) { HTMLUtils = brackets.getModule("language/HTMLUtils"), TokenUtils = brackets.getModule("utils/TokenUtils"), HintUtils = require("HintUtils"), - ScopeManager = require("ScopeManager"); + ScopeManager = require("ScopeManager"), + Acorn = require("thirdparty/acorn/acorn"), + Acorn_Loose = require("thirdparty/acorn/acorn_loose"); /** * Session objects encapsulate state associated with a hinting session @@ -53,7 +55,7 @@ define(function (require, exports, module) { /** * Get the builtin libraries tern is using. * - * @returns {Array.} - array of library names. + * @return {Array.} - array of library names. * @private */ Session.prototype._getBuiltins = function () { @@ -271,7 +273,7 @@ define(function (require, exports, module) { * * @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 + * @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 */ @@ -319,42 +321,150 @@ define(function (require, exports, module) { } 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, - showFunctionType:boolean, - context: string, - functionCallPos: {line:number, ch:number}}} - an Object consisting + 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. - * a {boolean} "showFunctionType" indicating if the function type should - * be displayed instead of normal hints. If "showFunctionType" is true, then - * then "functionCallPos" will be an object with line & col information of the - * function being called */ Session.prototype.getType = function () { - 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; - } - } var propertyLookup = false, - inFunctionCall = false, - showFunctionType = false, context = null, cursor = this.getCursor(), - functionCallPos, token = this.getToken(cursor), lexical; @@ -362,38 +472,6 @@ define(function (require, exports, module) { // if this token is part of a function call, then the tokens lexical info // will be annotated with "call" lexical = getLexicalState(token); - if (lexical.info === "call") { - inFunctionCall = true; - if (this.getQuery().length > 0) { - inFunctionCall = false; - showFunctionType = false; - } else { - showFunctionType = true; - // 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 occurrs, 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 - var col = lexical.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) { - functionCallPos = {line: line, ch: col}; - } - } - } if (token.type === "property") { propertyLookup = true; } @@ -403,14 +481,10 @@ define(function (require, exports, module) { propertyLookup = true; context = this.getContext(cursor); } - if (propertyLookup) { showFunctionType = false; } } return { property: propertyLookup, - inFunctionCall: inFunctionCall, - showFunctionType: showFunctionType, - functionCallPos: functionCallPos, context: context }; }; @@ -442,7 +516,7 @@ define(function (require, exports, module) { * * @param {string} query - the query prefix * @param {StringMatcher} matcher - the class to find query matches and sort the results - * @returns {hints: Array., needGuesses: boolean} - array of + * @return {hints: Array., needGuesses: boolean} - array of * matching hints. If needGuesses is true, then the caller needs to * request guesses and call getHints again. */ @@ -483,6 +557,7 @@ define(function (require, exports, module) { if (searchResult) { searchResult.value = hint.value; searchResult.guess = hint.guess; + searchResult.type = hint.type; if (hint.keyword !== undefined) { searchResult.keyword = hint.keyword; @@ -524,8 +599,6 @@ define(function (require, exports, module) { } StringMatch.multiFieldSort(hints, [ "matchGoodness", penalizeUnderscoreValueCompare ]); - } else if (type.showFunctionType) { - hints = this.getFunctionTypeHint(); } else { // identifiers, literals, and keywords hints = this.ternHints || []; hints = hints.concat(HintUtils.LITERALS); @@ -549,39 +622,119 @@ define(function (require, exports, module) { 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; }; - + /** - * Get the function type hint. This will format the hint so - * that it has the called variable name instead of just "fn()". + * The position of the function call for the current fnType. + * + * @param functionCallPos */ - Session.prototype.getFunctionTypeHint = function () { + 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, - hints = []; - - if (fnHint && (fnHint.substring(0, 3) === "fn(")) { - var sessionType = this.getType(), - cursor = sessionType.functionCallPos, - token = cursor ? this.getToken(cursor) : undefined, - varName; - if (token && - // only change the 'fn' when the token looks like a function - // name, and isn't some other kind of expression - (token.type === "variable" || - token.type === "variable-2" || - token.type === "property")) { - varName = token.string; - if (varName) { - fnHint = varName + fnHint.substr(2); + 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; } } - hints[0] = {value: fnHint, positions: []}; } - - hints.handleWideResults = true; - return hints; + + return {parameters: fnHint, currentIndex: currentArg}; }; /** @@ -630,12 +783,13 @@ define(function (require, exports, module) { }; /** - * Deterimine if the cursor is located in the name of a function declaration. - * This is so we can suppress hints when in a funtion name, as we do for variable and + * 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 decl. + * @return {boolean} - true if the current cursor position is in the name of a function + * declaration. */ Session.prototype.isFunctionName = function () { var cursor = this.getCursor(), diff --git a/src/extensions/default/JavaScriptCodeHints/keyboard.json b/src/extensions/default/JavaScriptCodeHints/keyboard.json new file mode 100644 index 00000000000..d4d4e5d1345 --- /dev/null +++ b/src/extensions/default/JavaScriptCodeHints/keyboard.json @@ -0,0 +1,11 @@ +{ + "showParameterHint": [ + { + "key": "Ctrl-Shift-Space" + }, + { + "key": "Ctrl-Shift-Space", + "platform": "mac" + } + ] +} \ No newline at end of file diff --git a/src/extensions/default/JavaScriptCodeHints/main.js b/src/extensions/default/JavaScriptCodeHints/main.js index ce74167212b..8c4db2a3391 100644 --- a/src/extensions/default/JavaScriptCodeHints/main.js +++ b/src/extensions/default/JavaScriptCodeHints/main.js @@ -27,24 +27,24 @@ define(function (require, exports, module) { "use strict"; - var CodeHintManager = brackets.getModule("editor/CodeHintManager"), - EditorManager = brackets.getModule("editor/EditorManager"), - DocumentManager = brackets.getModule("document/DocumentManager"), - Commands = brackets.getModule("command/Commands"), - CommandManager = brackets.getModule("command/CommandManager"), - Menus = brackets.getModule("command/Menus"), - Strings = brackets.getModule("strings"), - AppInit = brackets.getModule("utils/AppInit"), - ExtensionUtils = brackets.getModule("utils/ExtensionUtils"), - PerfUtils = brackets.getModule("utils/PerfUtils"), - StringUtils = brackets.getModule("utils/StringUtils"), - StringMatch = brackets.getModule("utils/StringMatch"), - LanguageManager = brackets.getModule("language/LanguageManager"), - ProjectManager = brackets.getModule("project/ProjectManager"), - HintUtils = require("HintUtils"), - ScopeManager = require("ScopeManager"), - Session = require("Session"), - Acorn = require("thirdparty/acorn/acorn"); + var CodeHintManager = brackets.getModule("editor/CodeHintManager"), + EditorManager = brackets.getModule("editor/EditorManager"), + DocumentManager = brackets.getModule("document/DocumentManager"), + Commands = brackets.getModule("command/Commands"), + CommandManager = brackets.getModule("command/CommandManager"), + Menus = brackets.getModule("command/Menus"), + AppInit = brackets.getModule("utils/AppInit"), + ExtensionUtils = brackets.getModule("utils/ExtensionUtils"), + PerfUtils = brackets.getModule("utils/PerfUtils"), + StringUtils = brackets.getModule("utils/StringUtils"), + StringMatch = brackets.getModule("utils/StringMatch"), + LanguageManager = brackets.getModule("language/LanguageManager"), + ProjectManager = brackets.getModule("project/ProjectManager"), + ParameterHintManager = require("ParameterHintManager"), + HintUtils = require("HintUtils"), + ScopeManager = require("ScopeManager"), + Session = require("Session"), + Acorn = require("thirdparty/acorn/acorn"); var session = null, // object that encapsulates the current session state cachedCursor = null, // last cursor of the current hinting session @@ -57,7 +57,7 @@ define(function (require, exports, module) { /** * Get the value of current session. * Used for unit testing. - * @returns {Session} - the current session. + * @return {Session} - the current session. */ function getSession() { return session; @@ -187,12 +187,12 @@ define(function (require, exports, module) { type = session.getType(); return !cachedHints || !cachedCursor || !cachedType || - cachedCursor.line !== cursor.line || - type.property !== cachedType.property || - type.context !== cachedType.context || - type.showFunctionType !== cachedType.showFunctionType || - (type.functionCallPos && cachedType.functionCallPos && - type.functionCallPos.ch !== cachedType.functionCallPos.ch); + cachedCursor.line !== cursor.line || + type.property !== cachedType.property || + type.context !== cachedType.context || + type.showFunctionType !== cachedType.showFunctionType || + (type.functionCallPos && cachedType.functionCallPos && + type.functionCallPos.ch !== cachedType.functionCallPos.ch); }; /** @@ -283,7 +283,7 @@ define(function (require, exports, module) { /** * Create a new StringMatcher instance, if needed. * - * @returns {StringMatcher} - a StringMatcher instance. + * @return {StringMatcher} - a StringMatcher instance. */ function getStringMatcher() { if (!matcher) { @@ -299,7 +299,7 @@ define(function (require, exports, module) { * Check if a hint response is pending. * * @param {jQuery.Deferred} deferredHints - deferred hint response - * @returns {boolean} - true if deferred hints are pending, false otherwise. + * @return {boolean} - true if deferred hints are pending, false otherwise. */ function hintsArePending(deferredHints) { return (deferredHints && !deferredHints.hasOwnProperty("hints") && @@ -467,14 +467,9 @@ define(function (require, exports, module) { query = session.getQuery(), start = {line: cursor.line, ch: cursor.ch - query.length}, end = {line: cursor.line, ch: cursor.ch}, - delimiter; + invalidPropertyName = false, + displayFunctionHint = false; - if (session.getType().showFunctionType) { - // function types show up as hints, so don't insert anything - // if we were displaying a function type - return false; - } - if (session.getType().property) { // if we're inserting a property name, we need to make sure the // hint is a valid property name. @@ -482,9 +477,8 @@ define(function (require, exports, module) { // it should result in one token, and that token should either be // a 'name' or a 'keyword', as javascript allows keywords as property names var tokenizer = Acorn.tokenize(completion); - var currentToken = tokenizer(), - invalidPropertyName = false; - + var currentToken = tokenizer(); + // the name is invalid if the hint is not a 'name' or 'keyword' token if (currentToken.type !== Acorn.tokTypes.name && !currentToken.type.keyword) { invalidPropertyName = true; @@ -508,6 +502,14 @@ define(function (require, exports, module) { } } } + + // If the completion is for a valid function, then append + // "()" to the function name. + if (!invalidPropertyName && /^fn\(/.test(hint.type)) { + completion = completion.concat("()"); + displayFunctionHint = true; + } + // Replace the current token with the completion // HACK (tracking adobe/brackets#1688): We talk to the private CodeMirror instance // directly to replace the range instead of using the Document, as we should. The @@ -515,11 +517,31 @@ define(function (require, exports, module) { // inline editors are open. session.editor._codeMirror.replaceRange(completion, start, end); + // If displaying a function hint, move the cursor inside the "()". + // Then pop-up a function hint. + if (displayFunctionHint) { + var pos = {line: start.line, ch: start.ch + completion.length - 1}; + + // stop cursor tracking before setting the cursor to avoid bringing + // down the current hint. + if (ParameterHintManager.isHintDisplayed()) { + ParameterHintManager.stopCursorTracking(session); + } + + session.editor._codeMirror.setCursor(pos); + + if (ParameterHintManager.isHintDisplayed()) { + ParameterHintManager.startCursorTracking(session); + } + + ParameterHintManager.popUpHint(ParameterHintManager.PUSH_EXISTING_HINT); + } + // Return false to indicate that another hinting session is not needed return false; }; - // load the extension + // load the extension AppInit.appReady(function () { /* @@ -533,6 +555,7 @@ define(function (require, exports, module) { session = new Session(editor); ScopeManager.handleEditorChange(session, editor.document, previousEditor ? previousEditor.document : null); + ParameterHintManager.setSession(session); cachedHints = null; } @@ -556,6 +579,8 @@ define(function (require, exports, module) { } ignoreChange = false; }); + + ParameterHintManager.installListeners(editor); } else { session = null; } @@ -763,6 +788,8 @@ define(function (require, exports, module) { var jsHints = new JSHints(); CodeHintManager.registerHintProvider(jsHints, HintUtils.SUPPORTED_LANGUAGES, 0); + ParameterHintManager.addCommands(); + // for unit testing exports.getSession = getSession; exports.jsHintProvider = jsHints; diff --git a/src/extensions/default/JavaScriptCodeHints/styles/brackets-js-hints.css b/src/extensions/default/JavaScriptCodeHints/styles/brackets-js-hints.css index cac1f162a38..24578bfed99 100644 --- a/src/extensions/default/JavaScriptCodeHints/styles/brackets-js-hints.css +++ b/src/extensions/default/JavaScriptCodeHints/styles/brackets-js-hints.css @@ -65,4 +65,26 @@ font-style: italic; } +#function-hint-container { + display: none; + background: #e0e1e3; + position: absolute; + z-index: 15; + left: 200px; + top: 40px; + pointer-events: none; + + padding: 1px 3px; + text-align: center; + + border-radius: 2px; + border: 1px solid #a6a8a8; +} + +#function-hint-container .function-hint-content { +} + +.brackets-js-hints .current-parameter { + font-weight: 500; +} diff --git a/src/extensions/default/JavaScriptCodeHints/tern-worker.js b/src/extensions/default/JavaScriptCodeHints/tern-worker.js index dc2d5d61bfd..5de5f235124 100644 --- a/src/extensions/default/JavaScriptCodeHints/tern-worker.js +++ b/src/extensions/default/JavaScriptCodeHints/tern-worker.js @@ -29,13 +29,15 @@ importScripts("thirdparty/requirejs/require.js"); (function () { "use strict"; - var MessageIds; - var Tern; - require(["./MessageIds"], function (messageIds) { + var MessageIds, HintUtils2; + var Tern, Infer; + require(["./MessageIds", "./HintUtils2"], function (messageIds, hintUtils2) { MessageIds = messageIds; + HintUtils2 = hintUtils2; var ternRequire = require.config({baseUrl: "./thirdparty"}); - ternRequire(["tern/lib/tern", "tern/plugin/requirejs", "tern/plugin/doc_comment"], function (tern, requirejs, docComment) { + ternRequire(["tern/lib/tern", "tern/lib/infer", "tern/plugin/requirejs", "tern/plugin/doc_comment"], function (tern, infer, requirejs, docComment) { Tern = tern; + Infer = infer; var ternServer = null; @@ -146,6 +148,7 @@ importScripts("thirdparty/requirejs/require.js"); query.depths = true; query.guess = true; query.origins = true; + query.types = true; query.expandWordForward = false; query.lineCharPositions = true; @@ -233,7 +236,7 @@ importScripts("thirdparty/requirejs/require.js"); //_log("tern properties: completions = " + data.completions.length); for (i = 0; i < data.completions.length; ++i) { var property = data.completions[i]; - properties.push({value: property, guess: true}); + properties.push({value: property, type: property.type, guess: true}); } } @@ -291,7 +294,173 @@ importScripts("thirdparty/requirejs/require.js"); } }); } - + + /** + * Given a Tern type object, convert it to an array of Objects, where each object describes + * a parameter. + * + * @param {!Infer.Fn} inferFnType - type to convert. + * @return {Array<{name: string, type: string, isOptional: boolean}>} where each entry in the array is a parameter. + */ + function getParameters(inferFnType) { + + // work around define functions before use warning. + var recordTypeToString, inferTypeToString, processInferFnTypeParameters, inferFnTypeToString; + + /** + * Convert an infer array type to a string. + * + * Formatted using google closure style. For example: + * + * "Array." + * + * @param {Infer.Arr} inferArrType + * + * @return {string} - array formatted in google closure style. + * + */ + function inferArrTypeToString(inferArrType) { + var result = "Array.<"; + + inferArrType.props[""].types.forEach(function (value, i) { + if (i > 0) { + result += ", "; + } + result += inferTypeToString(value); + }); + + // workaround case where types is zero length + if (inferArrType.props[""].types.length === 0) { + result += "Object"; + } + result += ">"; + + return result; + } + + /** + * Convert properties to a record type annotation. + * + * @param {Object} props + * @return {string} - record type annotation + */ + recordTypeToString = function (props) { + var result = "{", + first = true, + prop; + + for (prop in props) { + if (Object.prototype.hasOwnProperty.call(props, prop)) { + if (!first) { + result += ", "; + } + + first = false; + result += prop + ": " + inferTypeToString(props[prop]); + } + } + + result += "}"; + + return result; + }; + + /** + * Convert an infer type to a string. + * + * @param {*} inferType - one of the Infer's types; Infer.Prim, Infer.Arr, Infer.ANull. Infer.Fn functions are + * not handled here. + * + * @return {string} + * + */ + inferTypeToString = function (inferType) { + var result; + + if (inferType instanceof Infer.AVal) { + inferType = inferType.types[0]; + } + + if (inferType instanceof Infer.Prim) { + result = inferType.toString(); + if (result === "string") { + result = "String"; + } else if (result === "number") { + result = "Number"; + } else if (result === "boolean") { + result = "Boolean"; + } + } else if (inferType instanceof Infer.Arr) { + result = inferArrTypeToString(inferType); + } else if (inferType instanceof Infer.Fn) { + result = inferFnTypeToString(inferType); + } else if (inferType instanceof Infer.Obj) { + if (inferType.name === undefined) { + result = recordTypeToString(inferType.props); + } else { + result = inferType.name; + } + } else { + result = "Object"; + } + + return result; + }; + + /** + * Convert an infer function type to a Google closure type string. + * + * @param {Infer.Fn} inferType - type to convert. + * @return {string} - function type as a string. + */ + inferFnTypeToString = function (inferType) { + var result = "function(", + params = processInferFnTypeParameters(inferType); + + result += HintUtils2.formatParameterHint(params, null, null, true); + if (inferType.retval) { + result += "):"; + result += inferTypeToString(inferType.retval); + } + + return result; + }; + + /** + * Convert an infer function type to string. + * + * @param {*} inferType - one of the Infer's types; Infer.Fn, Infer.Prim, Infer.Arr, Infer.ANull + * @return {Array<{name: string, type: string, isOptional: boolean}>} where each entry in the array is a parameter. + */ + processInferFnTypeParameters = function (inferType) { + var params = [], + i; + + for (i = 0; i < inferType.args.length; i++) { + var param = {}, + name = inferType.argNames[i], + type = inferType.args[i]; + + if (name === undefined) { + name = "param" + (i + 1); + } + + if (name[name.length - 1] === "?") { + name = name.substring(0, name.length - 1); + param.isOptional = true; + } + + param.name = name; + param.type = inferTypeToString(type); + params.push(param); + } + + return params; + }; + + return processInferFnTypeParameters(inferFnType); + } + /** * Get the function type for the given offset * @@ -305,26 +474,48 @@ importScripts("thirdparty/requirejs/require.js"); */ function handleFunctionType(fileInfo, offset) { - var request = buildRequest(fileInfo, "type", offset); + var request = buildRequest(fileInfo, "type", offset), + error; - request.preferFunction = true; - - //_log("request " + dir + " " + file + " " + offset /*+ " " + text */); - ternServer.request(request, function (error, data) { - var fnType = ""; - if (error) { - _log("Error returned from Tern 'type' request: " + error); - } else { - fnType = data.type; - } - - // Post a message back to the main thread with the completions - self.postMessage({type: MessageIds.TERN_CALLED_FUNC_TYPE_MSG, - file: fileInfo.name, - offset: offset, - fnType: fnType - }); - }); + request.query.preferFunction = true; + + var fnType = ""; + try { + ternServer.request(request, function (error, data) { + + var file = ternServer.findFile(fileInfo.name); + + // convert query from partial to full offsets + var newOffset = offset; + if (fileInfo.type === MessageIds.TERN_FILE_INFO_TYPE_PART) { + newOffset = {line: offset.line + fileInfo.offsetLines, ch: offset.ch}; + } + + request = buildRequest(createEmptyUpdate(fileInfo.name), "type", newOffset); + + var expr = Tern.findQueryExpr(file, request.query); + Infer.resetGuessing(); + var type = Infer.expressionType(expr); + type = type.getFunctionType() || type.getType(); + if (type) { + fnType = getParameters(type); + } else { + error = "No parameter type found"; + _log(error); + } + }); + } catch (e) { + error = e.message; + _log("Error thrown in tern_worker:" + error); + } + + // Post a message back to the main thread with the completions + self.postMessage({type: MessageIds.TERN_CALLED_FUNC_TYPE_MSG, + file: fileInfo.name, + offset: offset, + fnType: fnType, + error: error + }); } /** diff --git a/src/extensions/default/JavaScriptCodeHints/unittest-files/basic-test-files/file1.js b/src/extensions/default/JavaScriptCodeHints/unittest-files/basic-test-files/file1.js index 64f1d2c54ca..08abfde7623 100644 --- a/src/extensions/default/JavaScriptCodeHints/unittest-files/basic-test-files/file1.js +++ b/src/extensions/default/JavaScriptCodeHints/unittest-files/basic-test-files/file1.js @@ -160,6 +160,60 @@ require(["MyModule"], function (myModule) { var x = myModule.c; }); + +/** + * Test record type google annoations. + * + * @param {{index: number, name: string}} t + */ +function testRecordTypeAnnotation(t) { + 'use strict'; + +} + +// More parameter hint testing +function functionHintTesting() { + "use strict"; + + // function with record type argument + testRecordTypeAnnotation(); + + // function with a function argument + arr.sort(); + + // function with array argument + arr.concat(); + + s.lastIndexOf(s, 5); + +} + +/** + * Test Array annotation + * + * @param {Array.} a + */ +function testArrayAnnotation(a) { + "use strict"; + +} + +testArrayAnnotation(); + +/** + * Test multiple optional args + * + * @param {number=} a + * @param {string=} b + * + */ +function testOptionalArgs(a, b) { + "use strict"; + +} + +testOptionalArgs(); + /* Add large comment to make this test over 250 lines which will trigger * partial updates to be used. * diff --git a/src/extensions/default/JavaScriptCodeHints/unittests.js b/src/extensions/default/JavaScriptCodeHints/unittests.js index 1380d1c526f..7a197912457 100644 --- a/src/extensions/default/JavaScriptCodeHints/unittests.js +++ b/src/extensions/default/JavaScriptCodeHints/unittests.js @@ -27,19 +27,21 @@ define(function (require, exports, module) { "use strict"; - var Commands = brackets.getModule("command/Commands"), - CommandManager = brackets.getModule("command/CommandManager"), - DocumentManager = brackets.getModule("document/DocumentManager"), - Editor = brackets.getModule("editor/Editor").Editor, - EditorManager = brackets.getModule("editor/EditorManager"), - FileUtils = brackets.getModule("file/FileUtils"), - NativeFileSystem = brackets.getModule("file/NativeFileSystem").NativeFileSystem, - SpecRunnerUtils = brackets.getModule("spec/SpecRunnerUtils"), - UnitTestReporter = brackets.getModule("test/UnitTestReporter"), - JSCodeHints = require("main"), - Preferences = require("Preferences"), - ScopeManager = require("ScopeManager"), - HintUtils = require("HintUtils"); + var Commands = brackets.getModule("command/Commands"), + CommandManager = brackets.getModule("command/CommandManager"), + DocumentManager = brackets.getModule("document/DocumentManager"), + Editor = brackets.getModule("editor/Editor").Editor, + EditorManager = brackets.getModule("editor/EditorManager"), + FileUtils = brackets.getModule("file/FileUtils"), + NativeFileSystem = brackets.getModule("file/NativeFileSystem").NativeFileSystem, + SpecRunnerUtils = brackets.getModule("spec/SpecRunnerUtils"), + UnitTestReporter = brackets.getModule("test/UnitTestReporter"), + JSCodeHints = require("main"), + Preferences = require("Preferences"), + ScopeManager = require("ScopeManager"), + HintUtils = require("HintUtils"), + HintUtils2 = require("HintUtils2"), + ParameterHintManager = require("ParameterHintManager"); var extensionPath = FileUtils.getNativeModuleDirectoryPath(module), testPath = extensionPath + "/unittest-files/basic-test-files/file1.js", @@ -153,6 +155,7 @@ define(function (require, exports, module) { runs(function () { callback(hintList); }); } + /* * Test if hints should be closed or not closed at a given position. * @@ -338,6 +341,90 @@ define(function (require, exports, module) { } + /** + * Verify there is no parameter hint at the current cursor. + */ + function expectNoParameterHint() { + expect(ParameterHintManager.popUpHint()).toBe(null); + } + + /** + * Verify the parameter hint is not visible. + */ + function expectParameterHintClosed() { + expect(ParameterHintManager.isHintDisplayed()).toBe(false); + } + + /* + * Wait for a hint response object to resolve, then apply a callback + * to the result + * + * @param {Object + jQuery.Deferred} hintObj - a hint response object, + * possibly deferred + * @param {Function} callback - the callback to apply to the resolved + * hint response object + */ + function _waitForParameterHint(hintObj, callback) { + var complete = false, + hint = null; + + hintObj.done(function () { + hint = JSCodeHints.getSession().getParameterHint(); + complete = true; + }); + + waitsFor(function () { + return complete; + }, "Expected parameter hint did not resolve", 3000); + + runs(function () { callback(hint); }); + } + + /** + * Show a function hint based on the code at the cursor. Verify the + * hint matches the passed in value. + * + * @param {Array<{name: string, type: string, isOptional: boolean}>} + * expectedParams - array of records, where each element of the array + * describes a function parameter. If null, then no hint is expected. + * @param {number} expectedParameter - the parameter at cursor. + */ + function expectParameterHint(expectedParams, expectedParameter) { + var request = ParameterHintManager.popUpHint(); + if (expectedParams === null) { + expect(request).toBe(null); + return; + } + + function expectHint(hint) { + var params = hint.parameters, + n = params.length, + i; + + // compare params to expected params + expect(params.length).toBe(expectedParams.length); + expect(hint.currentIndex).toBe(expectedParameter); + + for (i = 0; i < n; i++) { + + expect(params[i].name).toBe(expectedParams[i].name); + expect(params[i].type).toBe(expectedParams[i].type); + if (params[i].isOptional) { + expect(expectedParams[i].isOptional).toBeTruthy(); + } else { + expect(expectedParams[i].isOptional).toBeFalsy(); + } + } + + } + + if (request) { + _waitForParameterHint(request, expectHint); + } else { + expectHint(JSCodeHints.getSession().getParameterHint()); + } + } + function setupTest(path, primePump) { DocumentManager.getDocumentForPath(path).done(function (doc) { testDoc = doc; @@ -732,14 +819,14 @@ define(function (require, exports, module) { }); it("should list function type", function () { - var start = { line: 36, ch: 0 }, - middle = { line: 36, ch: 5 }; + var start = { line: 37, ch: 0 }, + middle = { line: 37, ch: 5 }; testDoc.replaceRange("funD(", start, start); testEditor.setCursorPos(middle); - var hintObj = expectHints(JSCodeHints.jsHintProvider); runs(function () { - hintsPresentExact(hintObj, ["funD(a: string, b: number) -> {x, y}"]); + expectParameterHint([{name: "a", type: "String"}, + {name: "b", type: "Number"}], 0); }); }); @@ -821,9 +908,8 @@ define(function (require, exports, module) { it("should list function type defined from .prototype", function () { var start = { line: 59, ch: 10 }; testEditor.setCursorPos(start); - var hintObj = expectHints(JSCodeHints.jsHintProvider); runs(function () { - hintsPresentExact(hintObj, ["calc(a4: number, b4: number) -> number"]); + expectParameterHint([{name: "a4", type: "Number"}, {name: "b4", type: "Number"}], 0); }); }); @@ -842,9 +928,8 @@ define(function (require, exports, module) { testDoc.replaceRange("myCustomer.setAmountDue(", start); testEditor.setCursorPos(testPos); - var hintObj = expectHints(JSCodeHints.jsHintProvider); runs(function () { - hintsPresentOrdered(hintObj, ["setAmountDue(amountDue: ?)"]); + expectParameterHint([{name: "amountDue", type: "Object"}], 0); }); }); @@ -852,9 +937,8 @@ define(function (require, exports, module) { var testPos = { line: 96, ch: 23 }; testEditor.setCursorPos(testPos); - var hintObj = expectHints(JSCodeHints.jsHintProvider); runs(function () { - hintsPresentOrdered(hintObj, ["innerFunc(arg: string) -> {t}"]); + expectParameterHint([{name: "arg", type: "String"}], 0); }); }); @@ -864,7 +948,7 @@ define(function (require, exports, module) { testEditor.setCursorPos(testPos); var hintObj = expectHints(JSCodeHints.jsHintProvider); runs(function () { - hintsPresentOrdered(hintObj, ["t() -> string"]); + expectParameterHint([], 0); }); }); @@ -887,20 +971,18 @@ define(function (require, exports, module) { var testPos = { line: 123, ch: 11 }; testEditor.setCursorPos(testPos); - var hintObj = expectHints(JSCodeHints.jsHintProvider); runs(function () { - hintsPresentExact(hintObj, ["funFuncArg(f: fn() -> number) -> number"]); + expectParameterHint([{name: "f", type: "function(): number"}], 0); }); }); - // parameter type anotation tests + // parameter type annotation tests it("should list parameter function type and best guess for function call/return types", function () { var testPos = { line: 139, ch: 12 }; testEditor.setCursorPos(testPos); - var hintObj = expectHints(JSCodeHints.jsHintProvider); runs(function () { - hintsPresentExact(hintObj, ["funFunc2Arg(f: fn(s: string, n: number) -> string)"]); + expectParameterHint([{name: "f", type: "function(String, Number):String"}], 0); }); }); @@ -920,9 +1002,8 @@ define(function (require, exports, module) { testDoc.replaceRange("funArr.index1(", start); testEditor.setCursorPos(testPos); - var hintObj = expectHints(JSCodeHints.jsHintProvider); runs(function () { - hintsPresentExact(hintObj, ["index1() -> number"]); + expectParameterHint([], 0); }); }); @@ -1061,7 +1142,7 @@ define(function (require, exports, module) { runs(function () { testEditor.setCursorPos(func); - expectNoHints(JSCodeHints.jsHintProvider); + expectNoParameterHint(); testEditor.setCursorPos(param); expectNoHints(JSCodeHints.jsHintProvider); testEditor.setCursorPos(variable); @@ -1113,9 +1194,99 @@ define(function (require, exports, module) { testPos = { line: 80, ch: 24 }; testDoc.replaceRange("myCustomer.setAmountDue(10)", start); testEditor.setCursorPos(testPos); - var hintObj = expectHints(JSCodeHints.jsHintProvider); runs(function () { - hintsPresentOrdered(hintObj, ["setAmountDue(amountDue: number)"]); + expectParameterHint([{name: "amountDue", type: "Number"}], 0); + }); + }); + + it("should list parameter hint for record type annotation", function () { + var testPos = { line: 178, ch: 25 }; + + testEditor.setCursorPos(testPos); + runs(function () { + expectParameterHint([{name: "t", type: "{index: Number, name: String}"}], -1); + }); + }); + + // Tern issue #208. Fixed in the latest tern + xit("should list parameter hint for optional parameters", function () { + var testPos = { line: 214, ch: 17 }; + + testEditor.setCursorPos(testPos); + runs(function () { + expectParameterHint([{name: "a", type: "Number", isOptional: true}, {name: "b", type: "String", isOptional: true}], 0); + }); + }); + + it("should list parameter hint for a function parameter", function () { + var testPos = { line: 181, ch: 12 }; + + testEditor.setCursorPos(testPos); + runs(function () { + expectParameterHint([{name: "compare", + type: "function(Object, Object):Number", + isOptional: true}], -1); + }); + }); + + it("should list parameter hint for an array parameter", function () { + var testPos = { line: 184, ch: 12 }; + testEditor.setCursorPos(testPos); + runs(function () { + expectParameterHint([{name: "other", type: "Array."}], -1); + }); + }); + + // Tern is not returning the correct function type info for the array annotation + xit("should list parameter hint for a source array annotation", function () { + var testPos = { line: 200, ch: 20 }; + testEditor.setCursorPos(testPos); + runs(function () { + expectParameterHint([{name: "a", type: "Array."}], 0); + }); + }); + + it("should close parameter hint when move off function", function () { + var testPos = { line: 184, ch: 12 }, + endPos = { line: 184, ch: 19 }; + testEditor.setCursorPos(testPos); + runs(function () { + expectParameterHint([{name: "other", type: "Array."}], -1); + }); + + runs(function () { + testEditor.setCursorPos(endPos); + expectParameterHintClosed(); + }); + }); + + it("should close parameter hint when move off function to another function", function () { + var testPos = { line: 184, ch: 12 }, + newPos = { line: 181, ch: 12 }; + testEditor.setCursorPos(testPos); + runs(function () { + expectParameterHint([{name: "other", type: "Array."}], -1); + }); + + runs(function () { + testEditor.setCursorPos(newPos); + expectParameterHintClosed(); + }); + }); + + it("should update current parameter as the cursor moves", function () { + var testPos = { line: 186, ch: 19 }, + newPos = { line: 186, ch: 20 }; + testEditor.setCursorPos(testPos); + runs(function () { + expectParameterHint([{name: "char", type: "String"}, + {name: "from", type: "Number", isOptional: true}], 0); + }); + + runs(function () { + testEditor.setCursorPos(newPos); + expectParameterHint([{name: "char", type: "String"}, + {name: "from", type: "Number", isOptional: true}], 1); }); }); @@ -1147,9 +1318,8 @@ define(function (require, exports, module) { var start = { line: 36, ch: 12 }; testEditor.setCursorPos(start); - var hintObj = expectHints(JSCodeHints.jsHintProvider); runs(function () { - hintsPresentExact(hintObj, ["foo(a: number) -> string"]); + expectParameterHint([{name: "a", type: "Number"}], 0); }); }); @@ -1157,9 +1327,8 @@ define(function (require, exports, module) { var start = { line: 22, ch: 17 }; testEditor.setCursorPos(start); - var hintObj = expectHints(JSCodeHints.jsHintProvider); runs(function () { - hintsPresentExact(hintObj, ["funD(a: string, b: number) -> {x, y}"]); + expectParameterHint([{name: "a", type: "String"}, {name: "b", type: "Number"}], 0); }); }); @@ -1167,9 +1336,8 @@ define(function (require, exports, module) { var start = { line: 23, ch: 17 }; testEditor.setCursorPos(start); - var hintObj = expectHints(JSCodeHints.jsHintProvider); runs(function () { - hintsPresentExact(hintObj, ["funE(paramE1: D1, paramE2: number)"]); + expectParameterHint([{name: "paramE1", type: "D1"}, {name: "paramE2", type: "Number"}], 0); }); }); @@ -1645,5 +1813,61 @@ define(function (require, exports, module) { }); }); + + describe("JavaScript Code Hinting format parameters tests", function () { + + it("should format parameters with no params", function () { + var params = []; + + expect(HintUtils2.formatParameterHint(params)).toBe(""); + }); + + it("should format parameters with one param", function () { + var params = [{name: "param1", type: "String"}]; + + expect(HintUtils2.formatParameterHint(params)).toBe("String param1"); + }); + + it("should format parameters with one optional param", function () { + var params = [{name: "param1", type: "String", isOptional: true}]; + + expect(HintUtils2.formatParameterHint(params)).toBe("[String param1]"); + }); + + it("should format parameters with one required, one optional param", function () { + var params = [{name: "param1", type: "String"}, + {name: "param2", type: "String", isOptional: true}]; + + expect(HintUtils2.formatParameterHint(params)).toBe("String param1, [String param2]"); + }); + + it("should format parameters with required param following an optional param", function () { + var params = [{name: "param1", type: "String"}, + {name: "param2", type: "String", isOptional: true}, + {name: "param3", type: "String"}]; + + expect(HintUtils2.formatParameterHint(params)).toBe("String param1, [String param2, String param3]"); + }); + + it("should format parameters with optional param following an optional param", function () { + var params = [{name: "param1", type: "String"}, + {name: "param2", type: "String", isOptional: true}, + {name: "param3", type: "String", isOptional: true}]; + + expect(HintUtils2.formatParameterHint(params)).toBe("String param1, [String param2], [String param3]"); + }); + + it("should format parameters with optional param following optional and required params", function () { + var params = [{name: "param1", type: "String"}, + {name: "param2", type: "String", isOptional: true}, + {name: "param3", type: "String"}, + {name: "param4", type: "String", isOptional: true}]; + + expect(HintUtils2.formatParameterHint(params)).toBe("String param1, [String param2, String param3], [String param4]"); + }); + + }); + + }); }); diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 30d3019b39d..434a2507889 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -438,7 +438,9 @@ define({ // extensions/default/JavaScriptCodeHints "CMD_JUMPTO_DEFINITION" : "Jump to Definition", - + "CMD_SHOW_PARAMETER_HINT" : "Show Parameter Hint", + "NO_ARGUMENTS" : "", + // extensions/default/JSLint "CMD_JSLINT" : "Enable JSLint", "CMD_JSLINT_FIRST_ERROR" : "Go to First JSLint Error",