summaryrefslogtreecommitdiffstats
path: root/devtools/client/sourceeditor/autocomplete.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/sourceeditor/autocomplete.js')
-rw-r--r--devtools/client/sourceeditor/autocomplete.js405
1 files changed, 405 insertions, 0 deletions
diff --git a/devtools/client/sourceeditor/autocomplete.js b/devtools/client/sourceeditor/autocomplete.js
new file mode 100644
index 000000000..357f25ed1
--- /dev/null
+++ b/devtools/client/sourceeditor/autocomplete.js
@@ -0,0 +1,405 @@
+/* vim:set ts=2 sw=2 sts=2 et tw=80:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const CSSCompleter = require("devtools/client/sourceeditor/css-autocompleter");
+const { AutocompletePopup } = require("devtools/client/shared/autocomplete-popup");
+const {KeyCodes} = require("devtools/client/shared/keycodes");
+
+const CM_TERN_SCRIPTS = [
+ "chrome://devtools/content/sourceeditor/codemirror/addon/tern/tern.js",
+ "chrome://devtools/content/sourceeditor/codemirror/addon/hint/show-hint.js"
+];
+
+const autocompleteMap = new WeakMap();
+
+/**
+ * Prepares an editor instance for autocompletion.
+ */
+function initializeAutoCompletion(ctx, options = {}) {
+ let { cm, ed, Editor } = ctx;
+ if (autocompleteMap.has(ed)) {
+ return;
+ }
+
+ let win = ed.container.contentWindow.wrappedJSObject;
+ let { CodeMirror, document } = win;
+
+ let completer = null;
+ let autocompleteKey = "Ctrl-" +
+ Editor.keyFor("autocompletion", { noaccel: true });
+ if (ed.config.mode == Editor.modes.js) {
+ let defs = [
+ require("./tern/browser"),
+ require("./tern/ecma5"),
+ ];
+
+ CM_TERN_SCRIPTS.forEach(ed.loadScript, ed);
+ win.tern = require("./tern/tern");
+ cm.tern = new CodeMirror.TernServer({
+ defs: defs,
+ typeTip: function (data) {
+ let tip = document.createElement("span");
+ tip.className = "CodeMirror-Tern-information";
+ let tipType = document.createElement("strong");
+ let tipText = document.createTextNode(data.type ||
+ cm.l10n("autocompletion.notFound"));
+ tipType.appendChild(tipText);
+ tip.appendChild(tipType);
+
+ if (data.doc) {
+ tip.appendChild(document.createTextNode(" — " + data.doc));
+ }
+
+ if (data.url) {
+ tip.appendChild(document.createTextNode(" "));
+ let docLink = document.createElement("a");
+ docLink.textContent = "[" + cm.l10n("autocompletion.docsLink") + "]";
+ docLink.href = data.url;
+ docLink.className = "theme-link";
+ docLink.setAttribute("target", "_blank");
+ tip.appendChild(docLink);
+ }
+
+ return tip;
+ }
+ });
+
+ let keyMap = {};
+ let updateArgHintsCallback = cm.tern.updateArgHints.bind(cm.tern, cm);
+ cm.on("cursorActivity", updateArgHintsCallback);
+
+ keyMap[autocompleteKey] = cmArg => {
+ cmArg.tern.getHint(cmArg, data => {
+ CodeMirror.on(data, "shown", () => ed.emit("before-suggest"));
+ CodeMirror.on(data, "close", () => ed.emit("after-suggest"));
+ CodeMirror.on(data, "select", () => ed.emit("suggestion-entered"));
+ CodeMirror.showHint(cmArg, (cmIgnore, cb) => cb(data), { async: true });
+ });
+ };
+
+ keyMap[Editor.keyFor("showInformation2", { noaccel: true })] = cmArg => {
+ cmArg.tern.showType(cmArg, null, () => {
+ ed.emit("show-information");
+ });
+ };
+ cm.addKeyMap(keyMap);
+
+ let destroyTern = function () {
+ ed.off("destroy", destroyTern);
+ cm.off("cursorActivity", updateArgHintsCallback);
+ cm.removeKeyMap(keyMap);
+ win.tern = cm.tern = null;
+ autocompleteMap.delete(ed);
+ };
+
+ ed.on("destroy", destroyTern);
+
+ autocompleteMap.set(ed, {
+ destroy: destroyTern
+ });
+
+ // TODO: Integrate tern autocompletion with this autocomplete API.
+ return;
+ } else if (ed.config.mode == Editor.modes.css) {
+ completer = new CSSCompleter({walker: options.walker,
+ cssProperties: options.cssProperties});
+ }
+
+ function insertSelectedPopupItem() {
+ let autocompleteState = autocompleteMap.get(ed);
+ if (!popup || !popup.isOpen || !autocompleteState) {
+ return false;
+ }
+
+ if (!autocompleteState.suggestionInsertedOnce && popup.selectedItem) {
+ autocompleteMap.get(ed).insertingSuggestion = true;
+ insertPopupItem(ed, popup.selectedItem);
+ }
+
+ popup.once("popup-closed", () => {
+ // This event is used in tests.
+ ed.emit("popup-hidden");
+ });
+ popup.hidePopup();
+ return true;
+ }
+
+ // Give each popup a new name to avoid sharing the elements.
+
+ let popup = new AutocompletePopup(win.parent.document, {
+ position: "bottom",
+ theme: "auto",
+ autoSelect: true,
+ onClick: insertSelectedPopupItem
+ });
+
+ let cycle = reverse => {
+ if (popup && popup.isOpen) {
+ cycleSuggestions(ed, reverse == true);
+ return null;
+ }
+
+ return CodeMirror.Pass;
+ };
+
+ let keyMap = {
+ "Tab": cycle,
+ "Down": cycle,
+ "Shift-Tab": cycle.bind(null, true),
+ "Up": cycle.bind(null, true),
+ "Enter": () => {
+ let wasHandled = insertSelectedPopupItem();
+ return wasHandled ? true : CodeMirror.Pass;
+ }
+ };
+
+ let autoCompleteCallback = autoComplete.bind(null, ctx);
+ let keypressCallback = onEditorKeypress.bind(null, ctx);
+ keyMap[autocompleteKey] = autoCompleteCallback;
+ cm.addKeyMap(keyMap);
+
+ cm.on("keydown", keypressCallback);
+ ed.on("change", autoCompleteCallback);
+ ed.on("destroy", destroy);
+
+ function destroy() {
+ ed.off("destroy", destroy);
+ cm.off("keydown", keypressCallback);
+ ed.off("change", autoCompleteCallback);
+ cm.removeKeyMap(keyMap);
+ popup.destroy();
+ keyMap = popup = completer = null;
+ autocompleteMap.delete(ed);
+ }
+
+ autocompleteMap.set(ed, {
+ popup: popup,
+ completer: completer,
+ keyMap: keyMap,
+ destroy: destroy,
+ insertingSuggestion: false,
+ suggestionInsertedOnce: false
+ });
+}
+
+/**
+ * Destroy autocompletion on an editor instance.
+ */
+function destroyAutoCompletion(ctx) {
+ let { ed } = ctx;
+ if (!autocompleteMap.has(ed)) {
+ return;
+ }
+
+ let {destroy} = autocompleteMap.get(ed);
+ destroy();
+}
+
+/**
+ * Provides suggestions to autocomplete the current token/word being typed.
+ */
+function autoComplete({ ed, cm }) {
+ let autocompleteOpts = autocompleteMap.get(ed);
+ let { completer, popup } = autocompleteOpts;
+ if (!completer || autocompleteOpts.insertingSuggestion ||
+ autocompleteOpts.doNotAutocomplete) {
+ autocompleteOpts.insertingSuggestion = false;
+ return;
+ }
+ let cur = ed.getCursor();
+ completer.complete(cm.getRange({line: 0, ch: 0}, cur), cur).then(suggestions => {
+ if (!suggestions || !suggestions.length || suggestions[0].preLabel == null) {
+ autocompleteOpts.suggestionInsertedOnce = false;
+ popup.once("popup-closed", () => {
+ // This event is used in tests.
+ ed.emit("after-suggest");
+ });
+ popup.hidePopup();
+ return;
+ }
+ // The cursor is at the end of the currently entered part of the token,
+ // like "backgr|" but we need to open the popup at the beginning of the
+ // character "b". Thus we need to calculate the width of the entered part
+ // of the token ("backgr" here). 4 comes from the popup's left padding.
+
+ let cursorElement = cm.display.cursorDiv.querySelector(".CodeMirror-cursor");
+ let left = suggestions[0].preLabel.length * cm.defaultCharWidth() + 4;
+ popup.hidePopup();
+ popup.setItems(suggestions);
+
+ popup.once("popup-opened", () => {
+ // This event is used in tests.
+ ed.emit("after-suggest");
+ });
+ popup.openPopup(cursorElement, -1 * left, 0);
+ autocompleteOpts.suggestionInsertedOnce = false;
+ }).then(null, e => console.error(e));
+}
+
+/**
+ * Inserts a popup item into the current cursor location
+ * in the editor.
+ */
+function insertPopupItem(ed, popupItem) {
+ let {preLabel, text} = popupItem;
+ let cur = ed.getCursor();
+ let textBeforeCursor = ed.getText(cur.line).substring(0, cur.ch);
+ let backwardsTextBeforeCursor = textBeforeCursor.split("").reverse().join("");
+ let backwardsPreLabel = preLabel.split("").reverse().join("");
+
+ // If there is additional text in the preLabel vs the line, then
+ // just insert the entire autocomplete text. An example:
+ // if you type 'a' and select '#about' from the autocomplete menu,
+ // then the final text needs to the end up as '#about'.
+ if (backwardsPreLabel.indexOf(backwardsTextBeforeCursor) === 0) {
+ ed.replaceText(text, {line: cur.line, ch: 0}, cur);
+ } else {
+ ed.replaceText(text.slice(preLabel.length), cur, cur);
+ }
+}
+
+/**
+ * Cycles through provided suggestions by the popup in a top to bottom manner
+ * when `reverse` is not true. Opposite otherwise.
+ */
+function cycleSuggestions(ed, reverse) {
+ let autocompleteOpts = autocompleteMap.get(ed);
+ let { popup } = autocompleteOpts;
+ let cur = ed.getCursor();
+ autocompleteOpts.insertingSuggestion = true;
+ if (!autocompleteOpts.suggestionInsertedOnce) {
+ autocompleteOpts.suggestionInsertedOnce = true;
+ let firstItem;
+ if (reverse) {
+ firstItem = popup.getItemAtIndex(popup.itemCount - 1);
+ popup.selectPreviousItem();
+ } else {
+ firstItem = popup.getItemAtIndex(0);
+ if (firstItem.label == firstItem.preLabel && popup.itemCount > 1) {
+ firstItem = popup.getItemAtIndex(1);
+ popup.selectNextItem();
+ }
+ }
+ if (popup.itemCount == 1) {
+ popup.hidePopup();
+ }
+ insertPopupItem(ed, firstItem);
+ } else {
+ let fromCur = {
+ line: cur.line,
+ ch: cur.ch - popup.selectedItem.text.length
+ };
+ if (reverse) {
+ popup.selectPreviousItem();
+ } else {
+ popup.selectNextItem();
+ }
+ ed.replaceText(popup.selectedItem.text, fromCur, cur);
+ }
+ // This event is used in tests.
+ ed.emit("suggestion-entered");
+}
+
+/**
+ * onkeydown handler for the editor instance to prevent autocompleting on some
+ * keypresses.
+ */
+function onEditorKeypress({ ed, Editor }, cm, event) {
+ let autocompleteOpts = autocompleteMap.get(ed);
+
+ // Do not try to autocomplete with multiple selections.
+ if (ed.hasMultipleSelections()) {
+ autocompleteOpts.doNotAutocomplete = true;
+ autocompleteOpts.popup.hidePopup();
+ return;
+ }
+
+ if ((event.ctrlKey || event.metaKey) && event.keyCode == KeyCodes.DOM_VK_SPACE) {
+ // When Ctrl/Cmd + Space is pressed, two simultaneous keypresses are emitted
+ // first one for just the Ctrl/Cmd and second one for combo. The first one
+ // leave the autocompleteOpts.doNotAutocomplete as true, so we have to make
+ // it false
+ autocompleteOpts.doNotAutocomplete = false;
+ return;
+ }
+
+ if (event.ctrlKey || event.metaKey || event.altKey) {
+ autocompleteOpts.doNotAutocomplete = true;
+ autocompleteOpts.popup.hidePopup();
+ return;
+ }
+
+ switch (event.keyCode) {
+ case KeyCodes.DOM_VK_RETURN:
+ autocompleteOpts.doNotAutocomplete = true;
+ break;
+ case KeyCodes.DOM_VK_ESCAPE:
+ if (autocompleteOpts.popup.isOpen) {
+ event.preventDefault();
+ }
+ break;
+ case KeyCodes.DOM_VK_LEFT:
+ case KeyCodes.DOM_VK_RIGHT:
+ case KeyCodes.DOM_VK_HOME:
+ case KeyCodes.DOM_VK_END:
+ autocompleteOpts.doNotAutocomplete = true;
+ autocompleteOpts.popup.hidePopup();
+ break;
+ case KeyCodes.DOM_VK_BACK_SPACE:
+ case KeyCodes.DOM_VK_DELETE:
+ if (ed.config.mode == Editor.modes.css) {
+ autocompleteOpts.completer.invalidateCache(ed.getCursor().line);
+ }
+ autocompleteOpts.doNotAutocomplete = true;
+ autocompleteOpts.popup.hidePopup();
+ break;
+ default:
+ autocompleteOpts.doNotAutocomplete = false;
+ }
+}
+
+/**
+ * Returns the private popup. This method is used by tests to test the feature.
+ */
+function getPopup({ ed }) {
+ if (autocompleteMap.has(ed)) {
+ return autocompleteMap.get(ed).popup;
+ }
+
+ return null;
+}
+
+/**
+ * Returns contextual information about the token covered by the caret if the
+ * implementation of completer supports it.
+ */
+function getInfoAt({ ed }, caret) {
+ if (autocompleteMap.has(ed)) {
+ let completer = autocompleteMap.get(ed).completer;
+ if (completer && completer.getInfoAt) {
+ return completer.getInfoAt(ed.getText(), caret);
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Returns whether autocompletion is enabled for this editor.
+ * Used for testing
+ */
+function isAutocompletionEnabled({ ed }) {
+ return autocompleteMap.has(ed);
+}
+
+// Export functions
+
+module.exports.initializeAutoCompletion = initializeAutoCompletion;
+module.exports.destroyAutoCompletion = destroyAutoCompletion;
+module.exports.getAutocompletionPopup = getPopup;
+module.exports.getInfoAt = getInfoAt;
+module.exports.isAutocompletionEnabled = isAutocompletionEnabled;