// 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.html; import static com.google.collide.client.code.autocomplete.html.CompletionType.ATTRIBUTE; import static com.google.collide.client.code.autocomplete.html.CompletionType.ELEMENT; import com.google.collide.client.code.autocomplete.AutocompleteController; import com.google.collide.client.code.autocomplete.AutocompleteProposal; import com.google.collide.client.code.autocomplete.AutocompleteProposals; import com.google.collide.client.code.autocomplete.AutocompleteResult; import com.google.collide.client.code.autocomplete.DefaultAutocompleteResult; import com.google.collide.client.code.autocomplete.LanguageSpecificAutocompleter; import com.google.collide.client.code.autocomplete.SignalEventEssence; import com.google.collide.client.code.autocomplete.AutocompleteProposals.ProposalWithContext; import com.google.collide.client.code.autocomplete.codegraph.CodeGraphAutocompleter; import com.google.collide.client.code.autocomplete.css.CssAutocompleter; import com.google.collide.client.code.autocomplete.html.HtmlAutocompleteProposals.HtmlProposalWithContext; import com.google.collide.client.code.autocomplete.integration.TaggableLineUtil; 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.client.util.PathUtil; import com.google.collide.client.util.collections.StringMultiset; import com.google.collide.codemirror2.CodeMirror2; import com.google.collide.codemirror2.HtmlState; import com.google.collide.codemirror2.SyntaxType; import com.google.collide.codemirror2.Token; import com.google.collide.codemirror2.TokenType; import com.google.collide.codemirror2.TokenUtil; import com.google.collide.codemirror2.XmlContext; import com.google.collide.codemirror2.XmlState; import com.google.collide.json.shared.JsonArray; import com.google.collide.shared.Pair; import com.google.collide.shared.TaggableLine; import com.google.collide.shared.document.Line; import com.google.collide.shared.document.Position; import com.google.collide.shared.document.anchor.Anchor; import com.google.collide.shared.document.anchor.AnchorManager; import com.google.collide.shared.document.anchor.AnchorType; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.gwt.event.dom.client.KeyCodes; import javax.annotation.Nonnull; /** * Autocompleter for HTML. * * */ public class HtmlAutocompleter extends LanguageSpecificAutocompleter { private static final String ELEMENT_SEPARATOR_CLOSE = ">"; private static final String ELEMENT_SELF_CLOSE = " />"; private static final String ELEMENT_SEPARATOR_OPEN_FINISHTAG = "</"; private static final String ATTRIBUTE_SEPARATOR_OPEN = "=\""; private static final String ATTRIBUTE_SEPARATOR_CLOSE = "\""; private static final HtmlTagsAndAttributes htmlAttributes = HtmlTagsAndAttributes.getInstance(); @VisibleForTesting static final AnchorType MODE_ANCHOR_TYPE = AnchorType.create(HtmlAutocompleter.class, "mode"); public static final AutocompleteResult RESULT_SLASH = new DefaultAutocompleteResult("/", "", 1); /** * Bean that holds {@link #findTag} results. */ private static class FindTagResult { /** * Index of last start-of-TAG token before cursor; -1 => not in this line. */ int startTagIndex = -1; /** * Index of last end-of-TAG token before cursor; -1 => not in this line. */ int endTagIndex = -1; /** * Token that "covers" the cursor; left token if cursor touches 2 tokens, */ Token inToken = null; /** * Number of characters between "inToken" start and the cursor position. */ int cut = 0; /** * Indicates that cursor is located inside tag. */ boolean inTag; } public static HtmlAutocompleter create(CssAutocompleter cssAutocompleter, CodeGraphAutocompleter jsAutocompleter) { return new HtmlAutocompleter(cssAutocompleter, jsAutocompleter); } /** * Finds token at cursor position and computes first and last token indexes * of surrounding tag. */ private static FindTagResult findTag(JsonArray<Token> tokens, boolean startsInTag, int column) { FindTagResult result = new FindTagResult(); result.inTag = startsInTag; // Number of tokens in line. final int size = tokens.size(); // Sum of lengths of processed tokens. int colCount = 0; // Index of next token. int index = 0; while (index < size) { Token token = tokens.get(index); colCount += token.getValue().length(); TokenType type = token.getType(); index++; if (TokenType.TAG == type) { // Toggle "inTag" flag and update tag bounds. if (result.inTag) { // Refer to XmlCodeAnalyzer parsing code notes. if (">".equals(token.getValue()) || "/>".equals(token.getValue())) { result.endTagIndex = index - 1; // Exit the loop if cursor is inside a closed tag. if (result.inToken != null) { return result; } result.inTag = false; } } else { if (CodeMirror2.HTML.equals(token.getMode())) { result.startTagIndex = index - 1; result.endTagIndex = -1; result.inTag = true; } } } // If token at cursor position is not found yet... if (result.inToken == null) { if (colCount >= column) { // We've found it at last! result.inToken = token; result.cut = colCount - column; if (!result.inTag) { // No proposals for text content. return result; } } } } return result; } /** * Builds {@link HtmlTagWithAttributes} from {@link FindTagResult} and tokens. * * <p>Scanning is similar to scanning in {@link XmlCodeAnalyzer}. */ private static HtmlTagWithAttributes buildTag( FindTagResult findTagResult, JsonArray<Token> tokens) { int index = findTagResult.startTagIndex; Token token = tokens.get(index); index++; String tagName = token.getValue().substring(1).trim(); HtmlTagWithAttributes result = new HtmlTagWithAttributes(tagName); StringMultiset tagAttributes = result.getAttributes(); while (index < findTagResult.endTagIndex) { token = tokens.get(index); index++; TokenType tokenType = token.getType(); if (TokenType.ATTRIBUTE == tokenType) { tagAttributes.add(token.getValue().toLowerCase()); } } result.setDirty(false); return result; } private CssAutocompleter cssAutocompleter; private CodeGraphAutocompleter jsAutocompleter; private DirtyStateTracker dirtyScope; private final Runnable dirtyScopeDelegate = new Runnable() { @Override public void run() { resetDirtyScope(); scheduleRequestForUpdatedProposals(); } }; private HtmlAutocompleter(CssAutocompleter cssAutocompleter, CodeGraphAutocompleter jsAutocompleter) { super(SyntaxType.HTML); this.cssAutocompleter = cssAutocompleter; this.jsAutocompleter = jsAutocompleter; } @Override protected void attach( DocumentParser parser, AutocompleteController controller, PathUtil filePath) { super.attach(parser, controller, filePath); if (cssAutocompleter != null) { cssAutocompleter.attach(parser, controller, filePath); } if (jsAutocompleter != null) { jsAutocompleter.attach(parser, controller, filePath); } } @Override public AutocompleteResult computeAutocompletionResult(ProposalWithContext proposal) { if (!(proposal instanceof HtmlProposalWithContext)) { if (proposal.getSyntaxType() == SyntaxType.JS) { return jsAutocompleter.computeAutocompletionResult(proposal); } else if (proposal.getSyntaxType() == SyntaxType.CSS) { return cssAutocompleter.computeAutocompletionResult(proposal); } else { throw new IllegalStateException( "Unexpected mode: " + proposal.getSyntaxType().getName()); } } HtmlProposalWithContext htmlProposal = (HtmlProposalWithContext) proposal; AutocompleteProposal selectedProposal = proposal.getItem(); String triggeringString = proposal.getContext().getTriggeringString(); String selectedName = selectedProposal.getName(); switch (htmlProposal.getType()) { case ELEMENT: if (htmlAttributes.isSelfClosedTag(selectedName)) { return new DefaultAutocompleteResult( selectedName + ELEMENT_SELF_CLOSE, triggeringString, selectedName.length()); } return new DefaultAutocompleteResult( selectedName + ELEMENT_SEPARATOR_CLOSE + ELEMENT_SEPARATOR_OPEN_FINISHTAG + selectedName + ELEMENT_SEPARATOR_CLOSE, triggeringString, selectedName.length() + ELEMENT_SEPARATOR_CLOSE.length()); case ATTRIBUTE: return new DefaultAutocompleteResult( selectedName + ATTRIBUTE_SEPARATOR_OPEN + ATTRIBUTE_SEPARATOR_CLOSE, triggeringString, selectedName.length() + ATTRIBUTE_SEPARATOR_OPEN.length()); default: throw new IllegalStateException( "Invocation of this method in not allowed for type " + htmlProposal.getType()); } } @Override public ExplicitAction getExplicitAction(SelectionModel selectionModel, SignalEventEssence signal, boolean popupIsShown) { Position cursor = selectionModel.getCursorPosition(); int cursorColumn = cursor.getColumn(); Line cursorLine = cursor.getLine(); String mode = getModeForColumn(cursorLine, cursorColumn); if (cssAutocompleter != null && CodeMirror2.CSS.equals(mode)) { return cssAutocompleter.getExplicitAction(selectionModel, signal, popupIsShown); } else if (jsAutocompleter != null && CodeMirror2.JAVASCRIPT.equals(mode)) { return jsAutocompleter.getExplicitAction(selectionModel, signal, popupIsShown); } else if (mode == null) { // This is possible if line is new and hasn't been processed yet. // We prefer to avoid annoying autocompletions. return ExplicitAction.DEFAULT; } char signalChar = signal.getChar(); if (signalChar == '/') { if (selectionModel.hasSelection()) { return ExplicitAction.DEFAULT; } if (cursorColumn == 0 || '<' != cursorLine.getText().charAt(cursorColumn - 1)) { return ExplicitAction.DEFAULT; } ParseResult<HtmlState> parseResult = getParser().getState(HtmlState.class, cursor, null); if (parseResult != null) { XmlState xmlState = parseResult.getState().getXmlState(); if (xmlState != null) { XmlContext xmlContext = xmlState.getContext(); if (xmlContext != null) { String tagName = xmlContext.getTagName(); if (tagName != null) { String addend = "/" + tagName + ELEMENT_SEPARATOR_CLOSE; return new ExplicitAction(new DefaultAutocompleteResult(addend, "", addend.length())); } } } } return ExplicitAction.DEFAULT; } if (!popupIsShown && (signalChar != 0) && (KeyCodes.KEY_ENTER != signalChar) && ('>' != signalChar)) { return ExplicitAction.DEFERRED_COMPLETE; } return ExplicitAction.DEFAULT; } /** * Finds autocomplete proposals based on the incomplete string. * * <p>Triggered * * <p>This method is triggered when:<ul> * <li>popup is hidden and user press ctrl-space (event consumed), * and explicit autocompletion failed * <li><b>or</b> popup is shown * </ul> */ @Override public AutocompleteProposals findAutocompletions( SelectionModel selection, SignalEventEssence trigger) { resetDirtyScope(); Position cursor = selection.getCursorPosition(); final Line line = cursor.getLine(); final int column = cursor.getColumn(); DocumentParser parser = getParser(); JsonArray<Token> tokens = parser.parseLineSync(line); if (tokens == null) { // This line has never been parsed yet. No variants. return AutocompleteProposals.EMPTY; } // We do not ruin parse results for "clean" lines. if (parser.isLineDirty(cursor.getLineNumber())) { // But "processing" of "dirty" line is harmless. XmlCodeAnalyzer.processLine(TaggableLineUtil.getPreviousLine(line), line, tokens); } String initialMode = parser.getInitialMode(line); JsonArray<Pair<Integer, String>> modes = TokenUtil.buildModes(initialMode, tokens); putModeAnchors(line, modes); String mode = TokenUtil.findModeForColumn(initialMode, modes, column); if (cssAutocompleter != null && CodeMirror2.CSS.equals(mode)) { return cssAutocompleter.findAutocompletions(selection, trigger); } else if (jsAutocompleter != null && CodeMirror2.JAVASCRIPT.equals(mode)) { return jsAutocompleter.findAutocompletions(selection, trigger); } if (selection.hasSelection()) { // Do not autocomplete in HTML when something is selected. return AutocompleteProposals.EMPTY; } HtmlTagWithAttributes tag = line.getTag(XmlCodeAnalyzer.TAG_START_TAG); boolean inTag = tag != null; if (column == 0) { // On first column we either add attribute or do nothing. if (inTag) { JsonArray<AutocompleteProposal> proposals = htmlAttributes.searchAttributes( tag.getTagName(), tag.getAttributes(), ""); return new HtmlAutocompleteProposals("", proposals, ATTRIBUTE); } return AutocompleteProposals.EMPTY; } FindTagResult findTagResult = findTag(tokens, inTag, column); if (!findTagResult.inTag || findTagResult.inToken == null) { // Ooops =( return AutocompleteProposals.EMPTY; } // If not unfinished tag at the beginning of line surrounds cursor... if (findTagResult.startTagIndex >= 0) { // Unfinished tag at he end of line may be used... if (findTagResult.endTagIndex == -1) { tag = line.getTag(XmlCodeAnalyzer.TAG_END_TAG); if (tag == null) { // Ooops =( return AutocompleteProposals.EMPTY; } } else { // Or new (temporary) object constructed. tag = buildTag(findTagResult, tokens); } } TokenType type = findTagResult.inToken.getType(); String value = findTagResult.inToken.getValue(); value = value.substring(0, value.length() - findTagResult.cut); if (TokenType.TAG == type) { value = value.substring(1).trim(); return new HtmlAutocompleteProposals( value, htmlAttributes.searchTags(value.toLowerCase()), ELEMENT); } if (TokenType.WHITESPACE == type || TokenType.ATTRIBUTE == type) { value = (TokenType.ATTRIBUTE == type) ? value : ""; JsonArray<AutocompleteProposal> proposals = htmlAttributes.searchAttributes( tag.getTagName(), tag.getAttributes(), value); dirtyScope = tag; dirtyScope.setDelegate(dirtyScopeDelegate); if (tag.isDirty()) { return AutocompleteProposals.PARSING; } return new HtmlAutocompleteProposals(value, proposals, ATTRIBUTE); } return AutocompleteProposals.EMPTY; } @Override protected void pause() { super.pause(); resetDirtyScope(); } private void resetDirtyScope() { if (dirtyScope != null) { dirtyScope.setDelegate(null); dirtyScope = null; } } @Override public void cleanup() { } /** * Updates line meta-information. * * @param line line being parsed * @param tokens tokens collected on the line */ public void updateModeAnchors(TaggableLine line, @Nonnull JsonArray<Token> tokens) { String initialMode = getParser().getInitialMode(line); JsonArray<Pair<Integer, String>> modes = TokenUtil.buildModes(initialMode, tokens); putModeAnchors(line, modes); } @VisibleForTesting String getModeForColumn(Line line, int column) { DocumentParser parser = getParser(); String mode = parser.getInitialMode(line); JsonArray<Anchor> anchors = AnchorManager.getAnchorsByTypeOrNull(line, MODE_ANCHOR_TYPE); if (anchors != null) { for (Anchor anchor : anchors.asIterable()) { if (anchor.getColumn() >= column) { // We'll use the previous mode. break; } mode = anchor.getValue(); } } return mode; } @VisibleForTesting void putModeAnchors(@Nonnull TaggableLine currentLine, @Nonnull JsonArray<Pair<Integer, String>> modes) { Preconditions.checkState(currentLine instanceof Line); // TODO: pull AnchorManager.getAnchorsByTypeOrNull to // TaggableLine interface (for decoupling). Line line = (Line) currentLine; AnchorManager anchorManager = line.getDocument().getAnchorManager(); Preconditions.checkNotNull(anchorManager); JsonArray<Anchor> oldAnchors = AnchorManager.getAnchorsByTypeOrNull(line, MODE_ANCHOR_TYPE); if (oldAnchors != null) { for (Anchor anchor : oldAnchors.asIterable()) { anchorManager.removeAnchor(anchor); } } for (Pair<Integer, String> pair : modes.asIterable()) { Anchor anchor = anchorManager.createAnchor(MODE_ANCHOR_TYPE, line, AnchorManager.IGNORE_LINE_NUMBER, pair.first); anchor.setRemovalStrategy(Anchor.RemovalStrategy.SHIFT); anchor.setValue(pair.second); } } }