src/editor/CodeHintManager.js
/*
* Copyright (c) 2012 - 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.
*
*/
/*
* __CodeHintManager Overview:__
*
* The CodeHintManager mediates the interaction between the editor and a
* collection of hint providers. If hints are requested explicitly by the
* user, then the providers registered for the current language are queried
* for their ability to provide hints in order of descending priority by
* way their hasHints methods. Character insertions may also constitute an
* implicit request for hints; consequently, providers for the current
* language are also queried on character insertion for both their ability to
* provide hints and also for the suitability of providing implicit hints
* in the given editor context.
*
* Once a provider responds affirmatively to a request for hints, the
* manager begins a hinting session with that provider, begins to query
* that provider for hints by way of its getHints method, and opens the
* hint list window. The hint list is kept open for the duration of the
* current session. The manager maintains the session until either:
*
* 1. the provider gives a null response to a request for hints;
* 2. a deferred response to getHints fails to resolve;
* 3. the user explicitly dismisses the hint list window;
* 4. the editor is closed or becomes inactive; or
* 5. the editor undergoes a "complex" change, e.g., a multi-character
* insertion, deletion or navigation.
*
* Single-character insertions, deletions or navigations may not
* invalidate the current session; in which case, each such change
* precipitates a successive call to getHints.
*
* If the user selects a hint from the rendered hint list then the
* provider is responsible for inserting the hint into the editor context
* for the current session by way of its insertHint method. The provider
* may use the return value of insertHint to request that an additional
* explicit hint request be triggered, potentially beginning a new
* session.
*
*
* __CodeHintProvider Overview:__
*
* A code hint provider should implement the following three functions:
*
* - `CodeHintProvider.hasHints(editor, implicitChar)`
* - `CodeHintProvider.getHints(implicitChar)`
* - `CodeHintProvider.insertHint(hint)`
*
* The behavior of these three functions is described in detail below.
*
* __CodeHintProvider.hasHints(editor, implicitChar)__
*
* The method by which a provider indicates intent to provide hints for a
* given editor. The manager calls this method both when hints are
* explicitly requested (via, e.g., Ctrl-Space) and when they may be
* implicitly requested as a result of character insertion in the editor.
* If the provider responds negatively then the manager may query other
* providers for hints. Otherwise, a new hinting session begins with this
* provider, during which the manager may repeatedly query the provider
* for hints via the getHints method. Note that no other providers will be
* queried until the hinting session ends.
*
* The implicitChar parameter is used to determine whether the hinting
* request is explicit or implicit. If the string is null then hints were
* explicitly requested and the provider should reply based on whether it
* is possible to return hints for the given editor context. Otherwise,
* the string contains just the last character inserted into the editor's
* document and the request for hints is implicit. In this case, the
* provider should determine whether it is both possible and appropriate
* to show hints. Because implicit hints can be triggered by every
* character insertion, hasHints may be called frequently; consequently,
* the provider should endeavor to return a value as quickly as possible.
*
* Because calls to hasHints imply that a hinting session is about to
* begin, a provider may wish to clean up cached data from previous
* sessions in this method. Similarly, if the provider returns true, it
* may wish to prepare to cache data suitable for the current session. In
* particular, it should keep a reference to the editor object so that it
* can access the editor in future calls to getHints and insertHints.
*
* param {Editor} editor
* A non-null editor object for the active window.
*
* param {string} implicitChar
* Either null, if the hinting request was explicit, or a single character
* that represents the last insertion and that indicates an implicit
* hinting request.
*
* return {boolean}
* Determines whether the current provider is able to provide hints for
* the given editor context and, in case implicitChar is non- null,
* whether it is appropriate to do so.
*
*
* __CodeHintProvider.getHints(implicitChar)__
*
* The method by which a provider provides hints for the editor context
* associated with the current session. The getHints method is called only
* if the provider asserted its willingness to provide hints in an earlier
* call to hasHints. The provider may return null or false, which indicates
* that the manager should end the current hinting session and close the hint
* list window; or true, which indicates that the manager should end the
* current hinting session but immediately attempt to begin a new hinting
* session by querying registered providers. Otherwise, the provider should
* return a response object that contains the following properties:
*
* 1. hints, a sorted array hints that the provider could later insert
* into the editor;
* 2. match, a string that the manager may use to emphasize substrings of
* hints in the hint list (case-insensitive); and
* 3. selectInitial, a boolean that indicates whether or not the
* first hint in the list should be selected by default.
* 4. handleWideResults, a boolean (or undefined) that indicates whether
* to allow result string to stretch width of display.
*
* If the array of
* hints is empty, then the manager will render an empty list, but the
* hinting session will remain open and the value of the selectInitial
* property is irrelevant.
*
* Alternatively, the provider may return a jQuery.Deferred object
* that resolves with an object with the structure described above. In
* this case, the manager will initially render the hint list window with
* a throbber and will render the actual list once the deferred object
* resolves to a response object. If a hint list has already been rendered
* (from an earlier call to getHints), then the old list will continue
* to be displayed until the new deferred has resolved.
*
* Both the manager and the provider can reject the deferred object. The
* manager will reject the deferred if the editor changes state (e.g., the
* user types a character) or if the hinting session ends (e.g., the user
* explicitly closes the hints by pressing escape). The provider can use
* this event to, e.g., abort an expensive computation. Consequently, the
* provider may assume that getHints will not be called again until the
* deferred object from the current call has resolved or been rejected. If
* the provider rejects the deferred, the manager will end the hinting
* session.
*
* The getHints method may be called by the manager repeatedly during a
* hinting session. Providers may wish to cache information for efficiency
* that may be useful throughout these sessions. The same editor context
* will be used throughout a session, and will only change during the
* session as a result of single-character insertions, deletions and
* cursor navigations. The provider may assume that, throughout the
* lifetime of the session, the getHints method will be called exactly
* once for each such editor change. Consequently, the provider may also
* assume that the document will not be changed outside of the editor
* during a session.
*
* param {string} implicitChar
* Either null, if the request to update the hint list was a result of
* navigation, or a single character that represents the last insertion.
*
* return {jQuery.Deferred|{
* hints: Array.<string|jQueryObject>,
* match: string,
* selectInitial: boolean,
* handleWideResults: boolean}}
*
* Null if the provider wishes to end the hinting session. Otherwise, a
* response object, possibly deferred, that provides 1. a sorted array
* hints that consists either of strings or jQuery objects; 2. a string
* match, possibly null, that is used by the manager to emphasize
* matching substrings when rendering the hint list; and 3. a boolean that
* indicates whether the first result, if one exists, should be selected
* by default in the hint list window. If match is non-null, then the
* hints should be strings.
*
* If the match is null, the manager will not
* attempt to emphasize any parts of the hints when rendering the hint
* list; instead the provider may return strings or jQuery objects for
* which emphasis is self-contained. For example, the strings may contain
* substrings that wrapped in bold tags. In this way, the provider can
* choose to let the manager handle emphasis for the simple and common case
* of prefix matching, or can provide its own emphasis if it wishes to use
* a more sophisticated matching algorithm.
*
*
* __CodeHintProvider.insertHint(hint)__
*
* The method by which a provider inserts a hint into the editor context
* associated with the current session. The provider may assume that the
* given hint was returned by the provider in some previous call in the
* current session to getHints, but not necessarily the most recent call.
* After the insertion has been performed, the current hinting session is
* closed. The provider should return a boolean value to indicate whether
* or not the end of the session should be immediately followed by a new
* explicit hinting request, which may result in a new hinting session
* being opened with some provider, but not necessarily the current one.
*
* param {string} hint
* The hint to be inserted into the editor context for the current session.
*
* return {boolean}
* Indicates whether the manager should follow hint insertion with an
* explicit hint request.
*
*
* __CodeHintProvider.insertHintOnTab__
*
* type {?boolean} insertHintOnTab
* Indicates whether the CodeHintManager should request that the provider of
* the current session insert the currently selected hint on tab key events,
* or if instead a tab character should be inserted into the editor. If omitted,
* the fallback behavior is determined by the CodeHintManager. The default
* behavior is to insert a tab character, but this can be changed with the
* insertHintOnTab Preference.
*/
define(function (require, exports, module) {
"use strict";
// Load dependent modules
var Commands = require("command/Commands"),
CommandManager = require("command/CommandManager"),
EditorManager = require("editor/EditorManager"),
Strings = require("strings"),
KeyEvent = require("utils/KeyEvent"),
CodeHintList = require("editor/CodeHintList").CodeHintList,
PreferencesManager = require("preferences/PreferencesManager");
var hintProviders = { "all" : [] },
lastChar = null,
sessionProvider = null,
sessionEditor = null,
hintList = null,
deferredHints = null,
keyDownEditor = null,
codeHintsEnabled = true,
codeHintOpened = false;
PreferencesManager.definePreference("showCodeHints", "boolean", true, {
description: Strings.DESCRIPTION_SHOW_CODE_HINTS
});
PreferencesManager.definePreference("insertHintOnTab", "boolean", false, {
description: Strings.DESCRIPTION_INSERT_HINT_ON_TAB
});
PreferencesManager.definePreference("maxCodeHints", "number", 50, {
description: Strings.DESCRIPTION_MAX_CODE_HINTS
});
PreferencesManager.on("change", "showCodeHints", function () {
codeHintsEnabled = PreferencesManager.get("showCodeHints");
});
/**
* Comparator to sort providers from high to low priority
*/
function _providerSort(a, b) {
return b.priority - a.priority;
}
/**
* The method by which a CodeHintProvider registers its willingness to
* providing hints for editors in a given language.
*
* @param {!CodeHintProvider} provider
* The hint provider to be registered, described below.
*
* @param {!Array.<string>} languageIds
* The set of language ids for which the provider is capable of
* providing hints. If the special language id name "all" is included then
* the provider may be called for any language.
*
* @param {?number} priority
* Used to break ties among hint providers for a particular language.
* Providers with a higher number will be asked for hints before those
* with a lower priority value. Defaults to zero.
*/
function registerHintProvider(providerInfo, languageIds, priority) {
var providerObj = { provider: providerInfo,
priority: priority || 0 };
if (languageIds.indexOf("all") !== -1) {
// Ignore anything else in languageIds and just register for every language. This includes
// the special "all" language since its key is in the hintProviders map from the beginning.
var languageId;
for (languageId in hintProviders) {
if (hintProviders.hasOwnProperty(languageId)) {
hintProviders[languageId].push(providerObj);
hintProviders[languageId].sort(_providerSort);
}
}
} else {
languageIds.forEach(function (languageId) {
if (!hintProviders[languageId]) {
// Initialize provider list with any existing all-language providers
hintProviders[languageId] = Array.prototype.concat(hintProviders.all);
}
hintProviders[languageId].push(providerObj);
hintProviders[languageId].sort(_providerSort);
});
}
}
/**
* @private
* Remove a code hint provider
* @param {!CodeHintProvider} provider Code hint provider to remove
* @param {(string|Array.<string>)=} targetLanguageId Optional set of
* language IDs for languages to remove the provider for. Defaults
* to all languages.
*/
function _removeHintProvider(provider, targetLanguageId) {
var index,
providers,
targetLanguageIdArr;
if (Array.isArray(targetLanguageId)) {
targetLanguageIdArr = targetLanguageId;
} else if (targetLanguageId) {
targetLanguageIdArr = [targetLanguageId];
} else {
targetLanguageIdArr = Object.keys(hintProviders);
}
targetLanguageIdArr.forEach(function (languageId) {
providers = hintProviders[languageId];
for (index = 0; index < providers.length; index++) {
if (providers[index].provider === provider) {
providers.splice(index, 1);
break;
}
}
});
}
/**
* Return the array of hint providers for the given language id.
* This gets called (potentially) on every keypress. So, it should be fast.
*
* @param {!string} languageId
* @return {?Array.<{provider: Object, priority: number}>}
*/
function _getProvidersForLanguageId(languageId) {
var providers = hintProviders[languageId] || hintProviders.all;
// Exclude providers that are explicitly disabled in the preferences.
// All code hint providers that do not have their constructor
// names listed in the preferences are enabled by default.
return providers.filter(function (provider) {
var prefKey = "codehint." + provider.provider.constructor.name;
return PreferencesManager.get(prefKey) !== false;
});
}
var _beginSession;
/**
* End the current hinting session
*/
function _endSession() {
if (!hintList) {
return;
}
hintList.close();
hintList = null;
codeHintOpened = false;
keyDownEditor = null;
sessionProvider = null;
sessionEditor = null;
if (deferredHints) {
deferredHints.reject();
deferredHints = null;
}
}
/**
* Is there a hinting session active for a given editor?
*
* NOTE: the sessionEditor, sessionProvider and hintList objects are
* only guaranteed to be initialized during an active session.
*
* @param {Editor} editor
* @return boolean
*/
function _inSession(editor) {
if (sessionEditor) {
if (sessionEditor === editor &&
(hintList.isOpen() ||
(deferredHints && deferredHints.state() === "pending"))) {
return true;
} else {
// the editor has changed
_endSession();
}
}
return false;
}
/**
* From an active hinting session, get hints from the current provider and
* render the hint list window.
*
* Assumes that it is called when a session is active (i.e. sessionProvider is not null).
*/
function _updateHintList(callMoveUpEvent) {
callMoveUpEvent = typeof callMoveUpEvent === "undefined" ? false : callMoveUpEvent;
if (deferredHints) {
deferredHints.reject();
deferredHints = null;
}
if (callMoveUpEvent) {
return hintList.callMoveUp(callMoveUpEvent);
}
var response = sessionProvider.getHints(lastChar);
lastChar = null;
if (!response) {
// the provider wishes to close the session
_endSession();
} else {
// if the response is true, end the session and begin another
if (response === true) {
var previousEditor = sessionEditor;
_endSession();
_beginSession(previousEditor);
} else if (response.hasOwnProperty("hints")) { // a synchronous response
if (hintList.isOpen()) {
// the session is open
hintList.update(response);
} else {
hintList.open(response);
}
} else { // response is a deferred
deferredHints = response;
response.done(function (hints) {
// Guard against timing issues where the session ends before the
// response gets a chance to execute the callback. If the session
// ends first while still waiting on the response, then hintList
// will get cleared up.
if (!hintList) {
return;
}
if (hintList.isOpen()) {
// the session is open
hintList.update(hints);
} else {
hintList.open(hints);
}
});
}
}
}
/**
* Try to begin a new hinting session.
* @param {Editor} editor
*/
_beginSession = function (editor) {
if (!codeHintsEnabled) {
return;
}
// Don't start a session if we have a multiple selection.
if (editor.getSelections().length > 1) {
return;
}
// Find a suitable provider, if any
var language = editor.getLanguageForSelection(),
enabledProviders = _getProvidersForLanguageId(language.getId());
enabledProviders.some(function (item, index) {
if (item.provider.hasHints(editor, lastChar)) {
sessionProvider = item.provider;
return true;
}
});
// If a provider is found, initialize the hint list and update it
if (sessionProvider) {
var insertHintOnTab,
maxCodeHints = PreferencesManager.get("maxCodeHints");
if (sessionProvider.insertHintOnTab !== undefined) {
insertHintOnTab = sessionProvider.insertHintOnTab;
} else {
insertHintOnTab = PreferencesManager.get("insertHintOnTab");
}
sessionEditor = editor;
hintList = new CodeHintList(sessionEditor, insertHintOnTab, maxCodeHints);
hintList.onHighlight(function ($hint, $hintDescContainer) {
if (hintList.enableDescription && $hintDescContainer && $hintDescContainer.length) {
// If the current hint provider listening for hint item highlight change
if (sessionProvider.onHighlight) {
sessionProvider.onHighlight($hint, $hintDescContainer);
}
// Update the hint description
if (sessionProvider.updateHintDescription) {
sessionProvider.updateHintDescription($hint, $hintDescContainer);
}
} else {
if (sessionProvider.onHighlight) {
sessionProvider.onHighlight($hint);
}
}
});
hintList.onSelect(function (hint) {
var restart = sessionProvider.insertHint(hint),
previousEditor = sessionEditor;
_endSession();
if (restart) {
_beginSession(previousEditor);
}
});
hintList.onClose(_endSession);
_updateHintList();
} else {
lastChar = null;
}
};
/**
* Handles keys related to displaying, searching, and navigating the hint list.
* This gets called before handleChange.
*
* TODO: Ideally, we'd get a more semantic event from the editor that told us
* what changed so that we could do all of this logic without looking at
* key events. Then, the purposes of handleKeyEvent and handleChange could be
* combined. Doing this well requires changing CodeMirror.
*
* @param {Event} jqEvent
* @param {Editor} editor
* @param {KeyboardEvent} event
*/
function _handleKeydownEvent(jqEvent, editor, event) {
keyDownEditor = editor;
if (!(event.ctrlKey || event.altKey || event.metaKey) &&
(event.keyCode === KeyEvent.DOM_VK_ENTER ||
event.keyCode === KeyEvent.DOM_VK_RETURN ||
event.keyCode === KeyEvent.DOM_VK_TAB)) {
lastChar = String.fromCharCode(event.keyCode);
}
}
function _handleKeypressEvent(jqEvent, editor, event) {
keyDownEditor = editor;
// Last inserted character, used later by handleChange
lastChar = String.fromCharCode(event.charCode);
// Pending Text is used in hintList._keydownHook()
if (hintList) {
hintList.addPendingText(lastChar);
}
}
function _handleKeyupEvent(jqEvent, editor, event) {
keyDownEditor = editor;
if (_inSession(editor)) {
if (event.keyCode === KeyEvent.DOM_VK_HOME ||
event.keyCode === KeyEvent.DOM_VK_END) {
_endSession();
} else if (event.keyCode === KeyEvent.DOM_VK_LEFT ||
event.keyCode === KeyEvent.DOM_VK_RIGHT ||
event.keyCode === KeyEvent.DOM_VK_BACK_SPACE) {
// Update the list after a simple navigation.
// We do this in "keyup" because we want the cursor position to be updated before
// we redraw the list.
_updateHintList();
} else if (event.ctrlKey && event.keyCode === KeyEvent.DOM_VK_SPACE) {
_updateHintList(event);
}
}
}
/**
* Handle a selection change event in the editor. If the selection becomes a
* multiple selection, end our current session.
* @param {BracketsEvent} event
* @param {Editor} editor
*/
function _handleCursorActivity(event, editor) {
if (_inSession(editor)) {
if (editor.getSelections().length > 1) {
_endSession();
}
}
}
/**
* Start a new implicit hinting session, or update the existing hint list.
* Called by the editor after handleKeyEvent, which is responsible for setting
* the lastChar.
*
* @param {Event} event
* @param {Editor} editor
* @param {{from: Pos, to: Pos, text: Array, origin: string}} changeList
*/
function _handleChange(event, editor, changeList) {
if (lastChar && editor === keyDownEditor) {
keyDownEditor = null;
if (_inSession(editor)) {
var charToRetest = lastChar;
_updateHintList();
// _updateHintList() may end a hinting session and clear lastChar, but a
// different provider may want to start a new session with the same character.
// So check whether current provider terminates the current hinting
// session. If so, then restore lastChar and restart a new session.
if (!_inSession(editor)) {
lastChar = charToRetest;
_beginSession(editor);
}
} else {
_beginSession(editor);
}
// Pending Text is used in hintList._keydownHook()
if (hintList && changeList[0] && changeList[0].text.length && changeList[0].text[0].length) {
var expectedLength = editor.getCursorPos().ch - changeList[0].from.ch,
newText = changeList[0].text[0];
// We may get extra text in newText since some features like auto
// close braces can append some text automatically.
// See https://github.com/adobe/brackets/issues/6345#issuecomment-32548064
// as an example of this scenario.
if (newText.length > expectedLength) {
// Strip off the extra text before calling removePendingText.
newText = newText.substr(0, expectedLength);
}
hintList.removePendingText(newText);
}
}
}
/**
* Test whether the provider has an exclusion that is still the same as text after the cursor.
*
* @param {string} exclusion - Text not to be overwritten when the provider inserts the selected hint.
* @param {string} textAfterCursor - Text that is immediately after the cursor position.
* @return {boolean} true if the exclusion is not null and is exactly the same as textAfterCursor,
* false otherwise.
*/
function hasValidExclusion(exclusion, textAfterCursor) {
return (exclusion && exclusion === textAfterCursor);
}
/**
* Test if a hint popup is open.
*
* @return {boolean} - true if the hints are open, false otherwise.
*/
function isOpen() {
return (hintList && hintList.isOpen());
}
/**
* Explicitly start a new session. If we have an existing session,
* then close the current one and restart a new one.
* @param {Editor} editor
*/
function _startNewSession(editor) {
if (isOpen()) {
return;
}
if (!editor) {
editor = EditorManager.getFocusedEditor();
}
if (editor) {
lastChar = null;
if (_inSession(editor)) {
_endSession();
}
// Begin a new explicit session
_beginSession(editor);
}
}
/**
* Expose CodeHintList for unit testing
*/
function _getCodeHintList() {
return hintList;
}
function activeEditorChangeHandler(event, current, previous) {
if (current) {
current.on("editorChange", _handleChange);
current.on("keydown", _handleKeydownEvent);
current.on("keypress", _handleKeypressEvent);
current.on("keyup", _handleKeyupEvent);
current.on("cursorActivity", _handleCursorActivity);
}
if (previous) {
//Removing all old Handlers
previous.off("editorChange", _handleChange);
previous.off("keydown", _handleKeydownEvent);
previous.off("keypress", _handleKeypressEvent);
previous.off("keyup", _handleKeyupEvent);
previous.off("cursorActivity", _handleCursorActivity);
}
}
activeEditorChangeHandler(null, EditorManager.getActiveEditor(), null);
EditorManager.on("activeEditorChange", activeEditorChangeHandler);
// Dismiss code hints before executing any command other than showing code hints since the command
// may make the current hinting session irrevalent after execution.
// For example, when the user hits Ctrl+K to open Quick Doc, it is
// pointless to keep the hint list since the user wants to view the Quick Doc
CommandManager.on("beforeExecuteCommand", function (event, commandId) {
if (commandId !== Commands.SHOW_CODE_HINTS) {
_endSession();
}
});
CommandManager.register(Strings.CMD_SHOW_CODE_HINTS, Commands.SHOW_CODE_HINTS, _startNewSession);
exports._getCodeHintList = _getCodeHintList;
exports._removeHintProvider = _removeHintProvider;
// Define public API
exports.isOpen = isOpen;
exports.registerHintProvider = registerHintProvider;
exports.hasValidExclusion = hasValidExclusion;
});