Blob Blame History Raw
/* 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 AutocompletePopup = require("devtools/client/shared/autocomplete-popup");
const CSSCompleter = require("devtools/client/sourceeditor/css-autocompleter");
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) {
      // eslint-disable-next-line mozilla/no-compare-against-boolean-literals
      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;
  }).catch(console.error);
}

/**
 * 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;