// Copyright 2012 Google Inc. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.google.collide.client.code.autocomplete; import com.google.collide.client.code.autocomplete.AutocompleteProposals.ProposalWithContext; import com.google.collide.client.code.autocomplete.LanguageSpecificAutocompleter.ExplicitAction; import com.google.collide.client.code.autocomplete.codegraph.CodeGraphAutocompleter; import com.google.collide.client.code.autocomplete.codegraph.LimitedContextFilePrefixIndex; import com.google.collide.client.code.autocomplete.codegraph.ParsingTask; import com.google.collide.client.code.autocomplete.codegraph.js.JsAutocompleter; import com.google.collide.client.code.autocomplete.codegraph.js.JsIndexUpdater; import com.google.collide.client.code.autocomplete.codegraph.py.PyAutocompleter; import com.google.collide.client.code.autocomplete.codegraph.py.PyIndexUpdater; import com.google.collide.client.code.autocomplete.css.CssAutocompleter; import com.google.collide.client.code.autocomplete.html.HtmlAutocompleter; import com.google.collide.client.code.autocomplete.html.XmlCodeAnalyzer; import com.google.collide.client.codeunderstanding.CubeClient; import com.google.collide.client.documentparser.DocumentParser; import com.google.collide.client.editor.Editor; import com.google.collide.client.util.PathUtil; import com.google.collide.client.util.ScheduledCommandExecutor; import com.google.collide.client.util.collections.SkipListStringBag; import com.google.collide.codemirror2.SyntaxType; import com.google.collide.codemirror2.Token; import com.google.collide.json.shared.JsonArray; import com.google.collide.shared.TaggableLine; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.gwt.core.client.Scheduler; import com.google.gwt.core.client.Scheduler.ScheduledCommand; import org.waveprotocol.wave.client.common.util.SignalEvent.KeySignalType; import javax.annotation.Nonnull; /** * Class to implement all the autocompletion support that is not specific to a * given language (e.g., css). */ public class Autocompleter { /** * Flag that specifies if proposals are filtered case-insensitively. * * <p>Once, this constant should become configuration option. */ public static final boolean CASE_INSENSITIVE = true; /** * Constant which limits number of results returned by * {@link LimitedContextFilePrefixIndex}. */ private static final int LOCAL_PREFIX_INDEX_LIMIT = 50; private static final XmlCodeAnalyzer XML_CODE_ANALYZER = new XmlCodeAnalyzer(); private final SkipListStringBag localPrefixIndexStorage; private final ParsingTask localPrefixIndexUpdater; private final PyIndexUpdater pyIndexUpdater; private final JsIndexUpdater jsIndexUpdater; private final HtmlAutocompleter htmlAutocompleter; private final CssAutocompleter cssAutocompleter; private final CodeGraphAutocompleter jsAutocompleter; private final CodeGraphAutocompleter pyAutocompleter; /** * Key that triggered autocomplete box opening. */ private SignalEventEssence boxTrigger; /** * Proxy that distributes notifications to all code analyzers. */ private final CodeAnalyzer distributingCodeAnalyzer = new CodeAnalyzer() { @Override public void onBeforeParse() { XML_CODE_ANALYZER.onBeforeParse(); localPrefixIndexUpdater.onBeforeParse(); pyIndexUpdater.onBeforeParse(); jsIndexUpdater.onBeforeParse(); } @Override public void onParseLine( TaggableLine previousLine, TaggableLine line, @Nonnull JsonArray<Token> tokens) { LanguageSpecificAutocompleter languageAutocompleter = getLanguageSpecificAutocompleter(); if (htmlAutocompleter == languageAutocompleter) { htmlAutocompleter.updateModeAnchors(line, tokens); XML_CODE_ANALYZER.onParseLine(previousLine, line, tokens); localPrefixIndexUpdater.onParseLine(previousLine, line, tokens); jsIndexUpdater.onParseLine(previousLine, line, tokens); } else if (pyAutocompleter == languageAutocompleter) { localPrefixIndexUpdater.onParseLine(previousLine, line, tokens); pyIndexUpdater.onParseLine(previousLine, line, tokens); } else if (jsAutocompleter == languageAutocompleter) { localPrefixIndexUpdater.onParseLine(previousLine, line, tokens); jsIndexUpdater.onParseLine(previousLine, line, tokens); } } @Override public void onAfterParse() { XML_CODE_ANALYZER.onAfterParse(); localPrefixIndexUpdater.onAfterParse(); pyIndexUpdater.onAfterParse(); jsIndexUpdater.onAfterParse(); } @Override public void onLinesDeleted(JsonArray<TaggableLine> deletedLines) { XML_CODE_ANALYZER.onLinesDeleted(deletedLines); localPrefixIndexUpdater.onLinesDeleted(deletedLines); pyIndexUpdater.onLinesDeleted(deletedLines); jsIndexUpdater.onLinesDeleted(deletedLines); } }; private class OnSelectCommand extends ScheduledCommandExecutor { private ProposalWithContext selectedProposal; @Override protected void execute() { Preconditions.checkNotNull(selectedProposal); reallyFinishAutocompletion(selectedProposal); selectedProposal = null; } public void scheduleAutocompletion(ProposalWithContext selectedProposal) { Preconditions.checkNotNull(selectedProposal); this.selectedProposal = selectedProposal; scheduleDeferred(); } } private final Editor editor; private boolean isAutocompleteInsertion = false; private AutocompleteController autocompleteController; private final AutocompleteBox popup; /** * Refreshes autocomplete popup contents (if it is displayed). * * <p>This method should be called when the code is modified. */ public void refresh() { if (autocompleteController == null) { return; } if (isAutocompleteInsertion) { return; } if (popup.isShowing()) { scheduleRequestAutocomplete(); } } /** * Callback passed to {@link AutocompleteController}. */ private final AutocompleterCallback callback = new AutocompleterCallback() { @Override public void rescheduleCompletionRequest() { scheduleRequestAutocomplete(); } }; private final OnSelectCommand onSelectCommand = new OnSelectCommand(); public static Autocompleter create( Editor editor, CubeClient cubeClient, final AutocompleteBox popup) { SkipListStringBag localPrefixIndexStorage = new SkipListStringBag(); LimitedContextFilePrefixIndex limitedContextFilePrefixIndex = new LimitedContextFilePrefixIndex( LOCAL_PREFIX_INDEX_LIMIT, localPrefixIndexStorage); CssAutocompleter cssAutocompleter = CssAutocompleter.create(); CodeGraphAutocompleter jsAutocompleter = JsAutocompleter.create( cubeClient, limitedContextFilePrefixIndex); HtmlAutocompleter htmlAutocompleter = HtmlAutocompleter.create( cssAutocompleter, jsAutocompleter); CodeGraphAutocompleter pyAutocompleter = PyAutocompleter.create( cubeClient, limitedContextFilePrefixIndex); PyIndexUpdater pyIndexUpdater = new PyIndexUpdater(); JsIndexUpdater jsIndexUpdater = new JsIndexUpdater(); return new Autocompleter(editor, popup, localPrefixIndexStorage, htmlAutocompleter, cssAutocompleter, jsAutocompleter, pyAutocompleter, pyIndexUpdater, jsIndexUpdater); } @VisibleForTesting Autocompleter(Editor editor, final AutocompleteBox popup, SkipListStringBag localPrefixIndexStorage, HtmlAutocompleter htmlAutocompleter, CssAutocompleter cssAutocompleter, CodeGraphAutocompleter jsAutocompleter, CodeGraphAutocompleter pyAutocompleter, PyIndexUpdater pyIndexUpdater, JsIndexUpdater jsIndexUpdater) { this.editor = editor; this.localPrefixIndexStorage = localPrefixIndexStorage; this.pyIndexUpdater = pyIndexUpdater; this.jsIndexUpdater = jsIndexUpdater; this.localPrefixIndexUpdater = new ParsingTask(localPrefixIndexStorage); this.cssAutocompleter = cssAutocompleter; this.jsAutocompleter = jsAutocompleter; this.htmlAutocompleter = htmlAutocompleter; this.pyAutocompleter = pyAutocompleter; this.popup = popup; popup.setDelegate(new AutocompleteBox.Events() { @Override public void onSelect(ProposalWithContext proposal) { if (AutocompleteProposals.NO_OP == proposal) { return; } // This is called on UI click - so surely we want popup to disappear. // TODO: It's a quick-fix; uncomment when autocompletions // become completer state free. //dismissAutocompleteBox(); onSelectCommand.scheduleAutocompletion(proposal); } @Override public void onCancel() { dismissAutocompleteBox(); } }); } /** * Asks popup and language-specific autocompleter to process key press * and schedules corresponding autocompletion requests, if required. * * @return {@code true} if event shouldn't be further processed / bubbled */ public boolean processKeyPress(SignalEventEssence trigger) { if (autocompleteController == null) { return false; } if (popup.isShowing() && popup.consumeKeySignal(trigger)) { return true; } if (isCtrlSpace(trigger)) { boxTrigger = trigger; scheduleRequestAutocomplete(); return true; } LanguageSpecificAutocompleter autocompleter = getLanguageSpecificAutocompleter(); ExplicitAction action = autocompleter.getExplicitAction(editor.getSelection(), trigger, popup.isShowing()); switch (action.getType()) { case EXPLICIT_COMPLETE: boxTrigger = null; performExplicitCompletion(action.getExplicitAutocompletion()); return true; case DEFERRED_COMPLETE: boxTrigger = trigger; scheduleRequestAutocomplete(); return false; case CLOSE_POPUP: dismissAutocompleteBox(); return false; default: return false; } } private static boolean isCtrlSpace(SignalEventEssence trigger) { return trigger.ctrlKey && (trigger.keyCode == ' ') && (trigger.type == KeySignalType.INPUT); } /** * Hides popup and prevents further activity. */ private void stop() { dismissAutocompleteBox(); if (this.autocompleteController != null) { this.autocompleteController.detach(); this.autocompleteController = null; } localPrefixIndexStorage.clear(); } /** * Setups for the document to be auto-completed. */ public void reset(PathUtil filePath, DocumentParser parser) { Preconditions.checkNotNull(filePath); Preconditions.checkNotNull(parser); stop(); LanguageSpecificAutocompleter autocompleter = getAutocompleter(parser.getSyntaxType()); this.autocompleteController = new AutocompleteController(autocompleter, callback); autocompleter.attach(parser, autocompleteController, filePath); } @VisibleForTesting protected LanguageSpecificAutocompleter getLanguageSpecificAutocompleter() { Preconditions.checkNotNull(autocompleteController); return autocompleteController.getLanguageSpecificAutocompleter(); } @VisibleForTesting AutocompleteController getController() { return autocompleteController; } @VisibleForTesting SyntaxType getMode() { return (autocompleteController == null) ? SyntaxType.NONE : autocompleteController.getLanguageSpecificAutocompleter().getMode(); } /** * Applies textual and UI changes specified with {@link AutocompleteResult}. */ private void applyChanges(AutocompleteResult result) { switch (result.getPopupAction()) { case CLOSE: dismissAutocompleteBox(); break; case OPEN: scheduleRequestAutocomplete(); break; } isAutocompleteInsertion = true; try { result.apply(editor); } finally { isAutocompleteInsertion = false; } } /** * Fetch changes from controller for selected proposal, hide popup; * apply changes. * * @param proposal proposal item selected by user */ @VisibleForTesting void reallyFinishAutocompletion(ProposalWithContext proposal) { applyChanges(autocompleteController.finish(proposal)); } /** * Dismisses the autocomplete box. * * <p>This is called when the user hits escape or types until * there are no more autocompletions or navigates away * from the autocompletion box position. */ public void dismissAutocompleteBox() { popup.dismiss(); boxTrigger = null; if (autocompleteController != null) { autocompleteController.pause(); } } /** * Schedules an asynchronous call to compute and display / perform * appropriate autocompletion proposals. */ private void scheduleRequestAutocomplete() { final SignalEventEssence trigger = boxTrigger; final AutocompleteController controller = autocompleteController; Scheduler.get().scheduleDeferred(new ScheduledCommand() { @Override public void execute() { requestAutocomplete(controller, trigger); } }); } private void performExplicitCompletion(AutocompleteResult completion) { Preconditions.checkState(!isAutocompleteInsertion); applyChanges(completion); } @VisibleForTesting void requestAutocomplete(AutocompleteController controller, SignalEventEssence trigger) { if (!controller.isAttached()) { return; } // TODO: If there is only one proposal that gives us nothing // then there are no proposals! AutocompleteProposals proposals = controller.start(editor.getSelection(), trigger); if (AutocompleteProposals.PARSING == proposals && popup.isShowing()) { // Do nothing to avoid flickering. } else if (!proposals.isEmpty()) { popup.positionAndShow(proposals); } else { dismissAutocompleteBox(); } } @VisibleForTesting protected LanguageSpecificAutocompleter getAutocompleter(SyntaxType mode) { switch (mode) { case HTML: return htmlAutocompleter; case JS: return jsAutocompleter; case CSS: return cssAutocompleter; case PY: return pyAutocompleter; default: return NoneAutocompleter.getInstance(); } } public void cleanup() { stop(); jsAutocompleter.cleanup(); pyAutocompleter.cleanup(); } public CodeAnalyzer getCodeAnalyzer() { return distributingCodeAnalyzer; } /** * Refreshes proposals list after cursor has been processed by parser. */ public void onCursorLineParsed() { refresh(); } public void onDocumentParsingFinished() { refresh(); } }