// 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.codegraph; import static com.google.collide.client.code.autocomplete.AutocompleteResult.PopupAction.CLOSE; import static com.google.collide.client.code.autocomplete.codegraph.ParseUtils.Context.IN_CODE; import static com.google.collide.client.code.autocomplete.codegraph.ParseUtils.Context.IN_COMMENT; import static com.google.collide.client.code.autocomplete.codegraph.ParseUtils.Context.IN_STRING; import static com.google.collide.client.code.autocomplete.codegraph.ParseUtils.Context.NOT_PARSED; import static com.google.collide.codemirror2.TokenType.STRING; import static com.google.gwt.event.dom.client.KeyCodes.KEY_BACKSPACE; import static org.waveprotocol.wave.client.common.util.SignalEvent.KeySignalType.DELETE; import com.google.collide.client.code.autocomplete.DefaultAutocompleteResult; import com.google.collide.client.code.autocomplete.SignalEventEssence; import com.google.collide.client.code.autocomplete.LanguageSpecificAutocompleter.ExplicitAction; import com.google.collide.client.documentparser.DocumentParser; import com.google.collide.client.documentparser.ParseResult; import com.google.collide.client.editor.selection.SelectionModel; import com.google.collide.codemirror2.State; import com.google.collide.codemirror2.Token; import com.google.collide.codemirror2.TokenType; import com.google.collide.json.shared.JsonArray; import com.google.collide.shared.document.Position; import com.google.collide.shared.util.JsonCollections; import com.google.collide.shared.util.StringUtils; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import javax.annotation.Nonnull; /** * Object that answers question about explicit actions and autocompletions. * */ public class ExplicitAutocompleter { private static final ExplicitAction RESULT_DELETE_AND_BACKSPACE = new ExplicitAction( new DefaultAutocompleteResult("", 0, 1, 0, 1, CLOSE, "")); /** * Compute left-trimmed text before position. * * @param position point of interest * @return beginning of line with removed spaces */ static String leftTrimmedLineTextBeforePosition(Position position) { return position.getLine().getText().substring(0, position.getColumn()).replaceAll("^\\s+", ""); } /** * Compute text after position. * * @param position point of interest * @return tail of line */ static String textAfterPosition(Position position) { return position.getLine().getText().substring(position.getColumn()); } static String textBeforePosition(Position position) { return position.getLine().getText().substring(0, position.getColumn()); } private boolean isExplicitDoublingChar(char keyCode) { return "(\"){'}[]".indexOf(keyCode) != -1; } protected ExplicitAction getExplicitAction(SelectionModel selectionModel, SignalEventEssence signal, boolean popupIsShown, @Nonnull DocumentParser parser) { char key = signal.getChar(); if (!popupIsShown && key == '.') { return ExplicitAction.DEFERRED_COMPLETE; } if (isExplicitDoublingChar(key)) { return getExplicitDoublingAutocompletion(signal, selectionModel, parser); } if (DELETE == signal.type && KEY_BACKSPACE == signal.keyCode && !signal.ctrlKey && !signal.altKey && !signal.shiftKey && !signal.metaKey) { return getExplicitBackspaceAutocompletion(selectionModel, parser); } if (Character.isLetterOrDigit(key) || key == '_' || key == 0) { return ExplicitAction.DEFAULT; } return popupIsShown ? ExplicitAction.CLOSE_POPUP : ExplicitAction.DEFAULT; } /** * Calculates explicit autocompletion result for "backspace" press. * * <p>This method works in assumption that there is no selection. * * <p>One "dangerous" case is when user press "backspace" at the very * beginning of the document. * * @return result that performs "del" or "del+bs" or nothing */ private ExplicitAction getExplicitBackspaceAutocompletion( SelectionModel selection, @Nonnull DocumentParser parser) { if (selection.hasSelection()) { return ExplicitAction.DEFAULT; } Position cursor = selection.getCursorPosition(); String textToCursor = leftTrimmedLineTextBeforePosition(cursor); String textAfterCursor = textAfterPosition(cursor); ParseUtils.ExtendedParseResult<State> extendedParseResult = ParseUtils .getExtendedParseResult(State.class, parser, cursor); ParseUtils.Context context = extendedParseResult.getContext(); char right = textAfterCursor.length() > 0 ? textAfterCursor.charAt(0) : 0; if (context == IN_STRING) { // This means that full token contains only string quotes. if ((String.valueOf(right)).equals(extendedParseResult.getLastTokenValue())) { return RESULT_DELETE_AND_BACKSPACE; } } else if (context == IN_CODE) { char left = textToCursor.length() > 0 ? textToCursor.charAt(textToCursor.length() - 1) : 0; if (left == '(' && right == ')') { return RESULT_DELETE_AND_BACKSPACE; } else if (left == '{' && right == '}') { return RESULT_DELETE_AND_BACKSPACE; } else if (left == '[' && right == ']') { return RESULT_DELETE_AND_BACKSPACE; } } return ExplicitAction.DEFAULT; } private ExplicitAction getExplicitDoublingAutocompletion( SignalEventEssence trigger, SelectionModel selection, @Nonnull DocumentParser parser) { Position[] selectionRange = selection.getSelectionRange(false); ParseUtils.ExtendedParseResult<State> extendedParseResult = ParseUtils .getExtendedParseResult(State.class, parser, selectionRange[0]); ParseUtils.Context context = extendedParseResult.getContext(); char key = trigger.getChar(); Preconditions.checkState(key != 0); if (context == NOT_PARSED || context == IN_COMMENT) { return ExplicitAction.DEFAULT; } String textAfterCursor = textAfterPosition(selectionRange[1]); int nextChar = -1; if (textAfterCursor.length() > 0) { nextChar = textAfterCursor.charAt(0); } boolean canPairParenthesis = nextChar == -1 || nextChar == ' ' || nextChar == ',' || nextChar == ';' || nextChar == '\n'; // TODO: Check if user has just fixed pairing? if (context != IN_STRING) { if ('(' == key && canPairParenthesis) { return new ExplicitAction(new DefaultAutocompleteResult("()", "", 1)); } else if ('[' == key && canPairParenthesis) { return new ExplicitAction(new DefaultAutocompleteResult("[]", "", 1)); } else if ('{' == key && canPairParenthesis) { return new ExplicitAction(new DefaultAutocompleteResult("{}", "", 1)); } else if ('"' == key || '\'' == key) { String doubleQuote = key + "" + key; if (!textBeforePosition(selectionRange[0]).endsWith(doubleQuote)) { return new ExplicitAction(new DefaultAutocompleteResult(doubleQuote, "", 1)); } } else if (!selection.hasSelection() && (key == nextChar) && (']' == key || ')' == key || '}' == key)) { // Testing what is more useful: pasting or passing. JsonArray<Token> tokens = parser.parseLineSync(selectionRange[0].getLine()); if (tokens != null) { int column = selectionRange[0].getColumn(); String closers = calculateClosingParens(tokens, column); String openers = calculateOpenParens(tokens, column); int match = StringUtils.findCommonPrefixLength(closers, openers); int newMatch = StringUtils.findCommonPrefixLength(key + closers, openers); if (newMatch <= match) { // With pasting results will be worse -> pass. return new ExplicitAction(DefaultAutocompleteResult.PASS_CHAR); } } } } else { if ((key == nextChar) && ('"' == key || '\'' == key)) { ParseResult<State> parseResult = parser.getState(State.class, selectionRange[0], key + " "); if (parseResult != null) { JsonArray<Token> tokens = parseResult.getTokens(); Preconditions.checkState(!tokens.isEmpty()); if (tokens.peek().getType() != STRING) { return new ExplicitAction(DefaultAutocompleteResult.PASS_CHAR); } } } } return ExplicitAction.DEFAULT; } @VisibleForTesting static String calculateOpenParens(JsonArray<Token> tokens, int column) { if (column == 0) { return ""; } JsonArray<String> parens = JsonCollections.createArray(); int colSum = 0; for (Token token : tokens.asIterable()) { String value = token.getValue(); if ("}".equals(value) || ")".equals(value) || "]".equals(value)) { if (parens.size() > 0) { if (value.equals(parens.peek())) { parens.pop(); } else { parens.clear(); } } } else if ("{".equals(value)) { parens.add("}"); } else if ("(".equals(value)) { parens.add(")"); } else if ("[".equals(value)) { parens.add("]"); } colSum += value.length(); if (colSum >= column) { break; } } parens.reverse(); return parens.join(""); } @VisibleForTesting static String calculateClosingParens(JsonArray<Token> tokens, int column) { StringBuilder result = new StringBuilder(); int colSum = 0; for (Token token : tokens.asIterable()) { String value = token.getValue(); if (colSum >= column) { if ("}".equals(value) || ")".equals(value) || "]".equals(value)) { result.append(value); } else if (token.getType() != TokenType.WHITESPACE) { break; } } colSum += value.length(); } return result.toString(); } }