blob: 5aac51f68cf19926644368b786d28ecea781e44d [file] [log] [blame]
/*
* Copyright (C) 2007 The Android Open Source Project
*
* Licensed under the Eclipse Public License, Version 1.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.eclipse.org/org/documents/epl-v10.php
*
* 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.android.ide.eclipse.adt.internal.editors;
import static com.android.SdkConstants.ANDROID_URI;
import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX;
import static com.android.SdkConstants.PREFIX_ANDROID;
import static com.android.SdkConstants.PREFIX_RESOURCE_REF;
import static com.android.SdkConstants.PREFIX_THEME_REF;
import static com.android.SdkConstants.UNIT_DP;
import static com.android.SdkConstants.UNIT_IN;
import static com.android.SdkConstants.UNIT_MM;
import static com.android.SdkConstants.UNIT_PT;
import static com.android.SdkConstants.UNIT_PX;
import static com.android.SdkConstants.UNIT_SP;
import static com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor.ATTRIBUTE_ICON_FILENAME;
import com.android.ide.common.api.IAttributeInfo;
import com.android.ide.common.api.IAttributeInfo.Format;
import com.android.ide.eclipse.adt.AdtPlugin;
import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor;
import com.android.ide.eclipse.adt.internal.editors.descriptors.ElementDescriptor;
import com.android.ide.eclipse.adt.internal.editors.descriptors.IDescriptorProvider;
import com.android.ide.eclipse.adt.internal.editors.descriptors.SeparatorAttributeDescriptor;
import com.android.ide.eclipse.adt.internal.editors.descriptors.TextValueDescriptor;
import com.android.ide.eclipse.adt.internal.editors.layout.gle2.DomUtilities;
import com.android.ide.eclipse.adt.internal.editors.uimodel.UiAttributeNode;
import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode;
import com.android.ide.eclipse.adt.internal.editors.uimodel.UiFlagAttributeNode;
import com.android.ide.eclipse.adt.internal.editors.uimodel.UiResourceAttributeNode;
import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData;
import com.android.utils.Pair;
import com.android.utils.XmlUtils;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.jdt.core.IType;
import org.eclipse.jdt.ui.ISharedImages;
import org.eclipse.jdt.ui.JavaUI;
import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IRegion;
import org.eclipse.jface.text.ITextViewer;
import org.eclipse.jface.text.contentassist.ICompletionProposal;
import org.eclipse.jface.text.contentassist.IContentAssistProcessor;
import org.eclipse.jface.text.contentassist.IContextInformation;
import org.eclipse.jface.text.contentassist.IContextInformationValidator;
import org.eclipse.jface.text.source.ISourceViewer;
import org.eclipse.swt.graphics.Image;
import org.eclipse.wst.sse.core.internal.provisional.IndexedRegion;
import org.w3c.dom.Node;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Content Assist Processor for Android XML files
* <p>
* Remaining corner cases:
* <ul>
* <li>Completion does not work right if there is a space between the = and the opening
* quote.
* <li>Replacement completion does not work right if the caret is to the left of the
* opening quote, where the opening quote is a single quote, and the replacement items use
* double quotes.
* </ul>
*/
@SuppressWarnings("restriction") // XML model
public abstract class AndroidContentAssist implements IContentAssistProcessor {
/** Regexp to detect a full attribute after an element tag.
* <pre>Syntax:
* name = "..." quoted string with all but < and "
* or:
* name = '...' quoted string with all but < and '
* </pre>
*/
private static Pattern sFirstAttribute = Pattern.compile(
"^ *[a-zA-Z_:]+ *= *(?:\"[^<\"]*\"|'[^<']*')"); //$NON-NLS-1$
/** Regexp to detect an element tag name */
private static Pattern sFirstElementWord = Pattern.compile("^[a-zA-Z0-9_:.-]+"); //$NON-NLS-1$
/** Regexp to detect whitespace */
private static Pattern sWhitespace = Pattern.compile("\\s+"); //$NON-NLS-1$
protected final static String ROOT_ELEMENT = "";
/** Descriptor of the root of the XML hierarchy. This a "fake" ElementDescriptor which
* is used to list all the possible roots given by actual implementations.
* DO NOT USE DIRECTLY. Call {@link #getRootDescriptor()} instead. */
private ElementDescriptor mRootDescriptor;
private final int mDescriptorId;
protected AndroidXmlEditor mEditor;
/**
* Constructor for AndroidContentAssist
* @param descriptorId An id for {@link AndroidTargetData#getDescriptorProvider(int)}.
* The Id can be one of {@link AndroidTargetData#DESCRIPTOR_MANIFEST},
* {@link AndroidTargetData#DESCRIPTOR_LAYOUT},
* {@link AndroidTargetData#DESCRIPTOR_MENU},
* or {@link AndroidTargetData#DESCRIPTOR_OTHER_XML}.
* All other values will throw an {@link IllegalArgumentException} later at runtime.
*/
public AndroidContentAssist(int descriptorId) {
mDescriptorId = descriptorId;
}
/**
* Returns a list of completion proposals based on the
* specified location within the document that corresponds
* to the current cursor position within the text viewer.
*
* @param viewer the viewer whose document is used to compute the proposals
* @param offset an offset within the document for which completions should be computed
* @return an array of completion proposals or <code>null</code> if no proposals are possible
*
* @see org.eclipse.jface.text.contentassist.IContentAssistProcessor#computeCompletionProposals(org.eclipse.jface.text.ITextViewer, int)
*/
@Override
public ICompletionProposal[] computeCompletionProposals(ITextViewer viewer, int offset) {
String wordPrefix = extractElementPrefix(viewer, offset);
if (mEditor == null) {
mEditor = AndroidXmlEditor.fromTextViewer(viewer);
if (mEditor == null) {
// This should not happen. Duck and forget.
AdtPlugin.log(IStatus.ERROR, "Editor not found during completion");
return null;
}
}
// List of proposals, in the order presented to the user.
List<ICompletionProposal> proposals = new ArrayList<ICompletionProposal>(80);
// Look up the caret context - where in an element, or between elements, or
// within an element's children, is the given caret offset located?
Pair<Node, Node> context = DomUtilities.getNodeContext(viewer.getDocument(), offset);
if (context == null) {
return null;
}
Node parentNode = context.getFirst();
Node currentNode = context.getSecond();
assert parentNode != null || currentNode != null;
UiElementNode rootUiNode = mEditor.getUiRootNode();
if (currentNode == null || currentNode.getNodeType() == Node.TEXT_NODE) {
UiElementNode parentUiNode =
rootUiNode == null ? null : rootUiNode.findXmlNode(parentNode);
computeTextValues(proposals, offset, parentNode, currentNode, parentUiNode,
wordPrefix);
} else if (currentNode.getNodeType() == Node.ELEMENT_NODE) {
String parent = currentNode.getNodeName();
AttribInfo info = parseAttributeInfo(viewer, offset, offset - wordPrefix.length());
char nextChar = extractChar(viewer, offset);
if (info != null) {
// check to see if we can find a UiElementNode matching this XML node
UiElementNode currentUiNode = rootUiNode == null
? null : rootUiNode.findXmlNode(currentNode);
computeAttributeProposals(proposals, viewer, offset, wordPrefix, currentUiNode,
parentNode, currentNode, parent, info, nextChar);
} else {
computeNonAttributeProposals(viewer, offset, wordPrefix, proposals, parentNode,
currentNode, parent, nextChar);
}
}
return proposals.toArray(new ICompletionProposal[proposals.size()]);
}
private void computeNonAttributeProposals(ITextViewer viewer, int offset, String wordPrefix,
List<ICompletionProposal> proposals, Node parentNode, Node currentNode, String parent,
char nextChar) {
if (startsWith(parent, wordPrefix)) {
// We are still editing the element's tag name, not the attributes
// (the element's tag name may not even be complete)
Object[] choices = getChoicesForElement(parent, currentNode);
if (choices == null || choices.length == 0) {
return;
}
int replaceLength = parent.length() - wordPrefix.length();
boolean isNew = replaceLength == 0 && nextNonspaceChar(viewer, offset) == '<';
// Special case: if we are right before the beginning of a new
// element, wipe out the replace length such that we insert before it,
// we don't edit the current element.
if (wordPrefix.length() == 0 && nextChar == '<') {
replaceLength = 0;
isNew = true;
}
// If we found some suggestions, do we need to add an opening "<" bracket
// for the element? We don't if the cursor is right after "<" or "</".
// Per XML Spec, there's no whitespace between "<" or "</" and the tag name.
char needTag = computeElementNeedTag(viewer, offset, wordPrefix);
addMatchingProposals(proposals, choices, offset,
parentNode != null ? parentNode : null, wordPrefix, needTag,
false /* isAttribute */, isNew, false /*isComplete*/,
replaceLength);
}
}
private void computeAttributeProposals(List<ICompletionProposal> proposals, ITextViewer viewer,
int offset, String wordPrefix, UiElementNode currentUiNode, Node parentNode,
Node currentNode, String parent, AttribInfo info, char nextChar) {
// We're editing attributes in an element node (either the attributes' names
// or their values).
if (info.isInValue) {
if (computeAttributeValues(proposals, offset, parent, info.name, currentNode,
wordPrefix, info.skipEndTag, info.replaceLength)) {
return;
}
}
// Look up attribute proposals based on descriptors
Object[] choices = getChoicesForAttribute(parent, currentNode, currentUiNode,
info, wordPrefix);
if (choices == null || choices.length == 0) {
return;
}
int replaceLength = info.replaceLength;
if (info.correctedPrefix != null) {
wordPrefix = info.correctedPrefix;
}
char needTag = info.needTag;
// Look to the right and see if we're followed by whitespace
boolean isNew = replaceLength == 0
&& (Character.isWhitespace(nextChar) || nextChar == '>' || nextChar == '/');
addMatchingProposals(proposals, choices, offset, parentNode != null ? parentNode : null,
wordPrefix, needTag, true /* isAttribute */, isNew, info.skipEndTag,
replaceLength);
}
private char computeElementNeedTag(ITextViewer viewer, int offset, String wordPrefix) {
char needTag = 0;
int offset2 = offset - wordPrefix.length() - 1;
char c1 = extractChar(viewer, offset2);
if (!((c1 == '<') || (c1 == '/' && extractChar(viewer, offset2 - 1) == '<'))) {
needTag = '<';
}
return needTag;
}
protected int computeTextReplaceLength(Node currentNode, int offset) {
if (currentNode == null) {
return 0;
}
assert currentNode != null && currentNode.getNodeType() == Node.TEXT_NODE;
String nodeValue = currentNode.getNodeValue();
int relativeOffset = offset - ((IndexedRegion) currentNode).getStartOffset();
int lineEnd = nodeValue.indexOf('\n', relativeOffset);
if (lineEnd == -1) {
lineEnd = nodeValue.length();
}
return lineEnd - relativeOffset;
}
/**
* Gets the choices when the user is editing the name of an XML element.
* <p/>
* The user is editing the name of an element (the "parent").
* Find the grand-parent and if one is found, return its children element list.
* The name which is being edited should be one of those.
* <p/>
* Example: <manifest><applic*cursor* => returns the list of all elements that
* can be found under <manifest>, of which <application> is one of the choices.
*
* @return an ElementDescriptor[] or null if no valid element was found.
*/
protected Object[] getChoicesForElement(String parent, Node currentNode) {
ElementDescriptor grandparent = null;
if (currentNode.getParentNode().getNodeType() == Node.ELEMENT_NODE) {
grandparent = getDescriptor(currentNode.getParentNode().getNodeName());
} else if (currentNode.getParentNode().getNodeType() == Node.DOCUMENT_NODE) {
grandparent = getRootDescriptor();
}
if (grandparent != null) {
for (ElementDescriptor e : grandparent.getChildren()) {
if (e.getXmlName().startsWith(parent)) {
return sort(grandparent.getChildren());
}
}
}
return null;
}
/** Non-destructively sort a list of ElementDescriptors and return the result */
protected static ElementDescriptor[] sort(ElementDescriptor[] elements) {
if (elements != null && elements.length > 1) {
// Sort alphabetically. Must make copy to not destroy original.
ElementDescriptor[] copy = new ElementDescriptor[elements.length];
System.arraycopy(elements, 0, copy, 0, elements.length);
Arrays.sort(copy, new Comparator<ElementDescriptor>() {
@Override
public int compare(ElementDescriptor e1, ElementDescriptor e2) {
return e1.getXmlLocalName().compareTo(e2.getXmlLocalName());
}
});
return copy;
}
return elements;
}
/**
* Gets the choices when the user is editing an XML attribute.
* <p/>
* In input, attrInfo contains details on the analyzed context, namely whether the
* user is editing an attribute value (isInValue) or an attribute name.
* <p/>
* In output, attrInfo also contains two possible new values (this is a hack to circumvent
* the lack of out-parameters in Java):
* - AttribInfo.correctedPrefix if the user has been editing an attribute value and it has
* been detected that what the user typed is different from what extractElementPrefix()
* predicted. This happens because extractElementPrefix() stops when a character that
* cannot be an element name appears whereas parseAttributeInfo() uses a grammar more
* lenient as suitable for attribute values.
* - AttribInfo.needTag will be non-zero if we find that the attribute completion proposal
* must be double-quoted.
* @param currentUiNode
*
* @return an AttributeDescriptor[] if the user is editing an attribute name.
* a String[] if the user is editing an attribute value with some known values,
* or null if nothing is known about the context.
*/
private Object[] getChoicesForAttribute(
String parent, Node currentNode, UiElementNode currentUiNode, AttribInfo attrInfo,
String wordPrefix) {
Object[] choices = null;
if (attrInfo.isInValue) {
// Editing an attribute's value... Get the attribute name and then the
// possible choices for the tuple(parent,attribute)
String value = attrInfo.valuePrefix;
if (value.startsWith("'") || value.startsWith("\"")) { //$NON-NLS-1$ //$NON-NLS-2$
value = value.substring(1);
// The prefix that was found at the beginning only scan for characters
// valid for tag name. We now know the real prefix for this attribute's
// value, which is needed to generate the completion choices below.
attrInfo.correctedPrefix = value;
} else {
attrInfo.needTag = '"';
}
if (currentUiNode != null) {
// look for an UI attribute matching the current attribute name
String attrName = attrInfo.name;
// remove any namespace prefix from the attribute name
int pos = attrName.indexOf(':');
if (pos >= 0) {
attrName = attrName.substring(pos + 1);
}
UiAttributeNode currAttrNode = null;
for (UiAttributeNode attrNode : currentUiNode.getAllUiAttributes()) {
if (attrNode.getDescriptor().getXmlLocalName().equals(attrName)) {
currAttrNode = attrNode;
break;
}
}
if (currAttrNode != null) {
choices = getAttributeValueChoices(currAttrNode, attrInfo, value);
}
}
if (choices == null) {
// fallback on the older descriptor-only based lookup.
// in order to properly handle the special case of the name attribute in
// the action tag, we need the grandparent of the action node, to know
// what type of actions we need.
// e.g. activity -> intent-filter -> action[@name]
String greatGrandParentName = null;
Node grandParent = currentNode.getParentNode();
if (grandParent != null) {
Node greatGrandParent = grandParent.getParentNode();
if (greatGrandParent != null) {
greatGrandParentName = greatGrandParent.getLocalName();
}
}
AndroidTargetData data = mEditor.getTargetData();
if (data != null) {
choices = data.getAttributeValues(parent, attrInfo.name, greatGrandParentName);
}
}
} else {
// Editing an attribute's name... Get attributes valid for the parent node.
if (currentUiNode != null) {
choices = currentUiNode.getAttributeDescriptors();
} else {
ElementDescriptor parentDesc = getDescriptor(parent);
if (parentDesc != null) {
choices = parentDesc.getAttributes();
}
}
}
return choices;
}
protected Object[] getAttributeValueChoices(UiAttributeNode currAttrNode, AttribInfo attrInfo,
String value) {
Object[] choices;
int pos;
choices = currAttrNode.getPossibleValues(value);
if (choices != null && currAttrNode instanceof UiResourceAttributeNode) {
attrInfo.skipEndTag = false;
}
if (currAttrNode instanceof UiFlagAttributeNode) {
// A "flag" can consist of several values separated by "or" (|).
// If the correct prefix contains such a pipe character, we change
// it so that only the currently edited value is completed.
pos = value.lastIndexOf('|');
if (pos >= 0) {
attrInfo.correctedPrefix = value = value.substring(pos + 1);
attrInfo.needTag = 0;
}
attrInfo.skipEndTag = false;
}
// Should we do suffix completion on dimension units etc?
choices = completeSuffix(choices, value, currAttrNode);
// Check to see if the user is attempting resource completion
AttributeDescriptor attributeDescriptor = currAttrNode.getDescriptor();
IAttributeInfo attributeInfo = attributeDescriptor.getAttributeInfo();
if (value.startsWith(PREFIX_RESOURCE_REF)
&& !attributeInfo.getFormats().contains(Format.REFERENCE)) {
// Special case: If the attribute value looks like a reference to a
// resource, offer to complete it, since in many cases our metadata
// does not correctly state whether a resource value is allowed. We don't
// offer these for an empty completion context, but if the user has
// actually typed "@", in that case list resource matches.
// For example, for android:minHeight this makes completion on @dimen/
// possible.
choices = UiResourceAttributeNode.computeResourceStringMatches(
mEditor, attributeDescriptor, value);
attrInfo.skipEndTag = false;
} else if (value.startsWith(PREFIX_THEME_REF)
&& !attributeInfo.getFormats().contains(Format.REFERENCE)) {
choices = UiResourceAttributeNode.computeResourceStringMatches(
mEditor, attributeDescriptor, value);
attrInfo.skipEndTag = false;
}
return choices;
}
/**
* Compute attribute values. Return true if the complete set of values was
* added, so addition descriptor information should not be added.
*/
protected boolean computeAttributeValues(List<ICompletionProposal> proposals, int offset,
String parentTagName, String attributeName, Node node, String wordPrefix,
boolean skipEndTag, int replaceLength) {
return false;
}
protected void computeTextValues(List<ICompletionProposal> proposals, int offset,
Node parentNode, Node currentNode, UiElementNode uiParent,
String wordPrefix) {
if (parentNode != null) {
// Examine the parent of the text node.
Object[] choices = getElementChoicesForTextNode(parentNode);
if (choices != null && choices.length > 0) {
ISourceViewer viewer = mEditor.getStructuredSourceViewer();
char needTag = computeElementNeedTag(viewer, offset, wordPrefix);
int replaceLength = 0;
addMatchingProposals(proposals, choices,
offset, parentNode, wordPrefix, needTag,
false /* isAttribute */,
false /*isNew*/,
false /*isComplete*/,
replaceLength);
}
}
}
/**
* Gets the choices when the user is editing an XML text node.
* <p/>
* This means the user is editing outside of any XML element or attribute.
* Simply return the list of XML elements that can be present there, based on the
* parent of the current node.
*
* @return An ElementDescriptor[] or null.
*/
protected ElementDescriptor[] getElementChoicesForTextNode(Node parentNode) {
ElementDescriptor[] choices = null;
String parent;
if (parentNode.getNodeType() == Node.ELEMENT_NODE) {
// We're editing a text node which parent is an element node. Limit
// content assist to elements valid for the parent.
parent = parentNode.getNodeName();
ElementDescriptor desc = getDescriptor(parent);
if (desc == null && parent.indexOf('.') != -1) {
// The parent is a custom view and we don't have metadata about its
// allowable children, so just assume any normal layout tag is
// legal
desc = mRootDescriptor;
}
if (desc != null) {
choices = sort(desc.getChildren());
}
} else if (parentNode.getNodeType() == Node.DOCUMENT_NODE) {
// We're editing a text node at the first level (i.e. root node).
// Limit content assist to the only valid root elements.
choices = sort(getRootDescriptor().getChildren());
}
return choices;
}
/**
* Given a list of choices, adds in any that match the current prefix into the
* proposals list.
* <p/>
* Choices is an object array. Items of the array can be:
* - ElementDescriptor: a possible element descriptor which XML name should be completed.
* - AttributeDescriptor: a possible attribute descriptor which XML name should be completed.
* - String: string values to display as-is to the user. Typically those are possible
* values for a given attribute.
* - Pair of Strings: the first value is the keyword to insert, and the second value
* is the tooltip/help for the value to be displayed in the documentation popup.
*/
protected void addMatchingProposals(List<ICompletionProposal> proposals, Object[] choices,
int offset, Node currentNode, String wordPrefix, char needTag,
boolean isAttribute, boolean isNew, boolean skipEndTag, int replaceLength) {
if (choices == null) {
return;
}
Map<String, String> nsUriMap = new HashMap<String, String>();
boolean haveLayoutParams = false;
for (Object choice : choices) {
String keyword = null;
String nsPrefix = null;
String nsUri = null;
Image icon = null;
String tooltip = null;
if (choice instanceof ElementDescriptor) {
keyword = ((ElementDescriptor)choice).getXmlName();
icon = ((ElementDescriptor)choice).getGenericIcon();
// Tooltip computed lazily in {@link CompletionProposal}
} else if (choice instanceof TextValueDescriptor) {
continue; // Value nodes are not part of the completion choices
} else if (choice instanceof SeparatorAttributeDescriptor) {
continue; // not real attribute descriptors
} else if (choice instanceof AttributeDescriptor) {
keyword = ((AttributeDescriptor)choice).getXmlLocalName();
icon = ((AttributeDescriptor)choice).getGenericIcon();
// Tooltip computed lazily in {@link CompletionProposal}
// Get the namespace URI for the attribute. Note that some attributes
// do not have a namespace and thus return null here.
nsUri = ((AttributeDescriptor)choice).getNamespaceUri();
if (nsUri != null) {
nsPrefix = nsUriMap.get(nsUri);
if (nsPrefix == null) {
nsPrefix = XmlUtils.lookupNamespacePrefix(currentNode, nsUri, false);
nsUriMap.put(nsUri, nsPrefix);
}
}
if (nsPrefix != null) {
nsPrefix += ":"; //$NON-NLS-1$
}
} else if (choice instanceof String) {
keyword = (String) choice;
if (isAttribute) {
icon = IconFactory.getInstance().getIcon(ATTRIBUTE_ICON_FILENAME);
}
} else if (choice instanceof Pair<?, ?>) {
@SuppressWarnings("unchecked")
Pair<String, String> pair = (Pair<String, String>) choice;
keyword = pair.getFirst();
tooltip = pair.getSecond();
if (isAttribute) {
icon = IconFactory.getInstance().getIcon(ATTRIBUTE_ICON_FILENAME);
}
} else if (choice instanceof IType) {
IType type = (IType) choice;
keyword = type.getFullyQualifiedName();
icon = JavaUI.getSharedImages().getImage(ISharedImages.IMG_OBJS_CUNIT);
} else {
continue; // discard unknown choice
}
String nsKeyword = nsPrefix == null ? keyword : (nsPrefix + keyword);
if (nameStartsWith(nsKeyword, wordPrefix, nsPrefix)) {
keyword = nsKeyword;
String endTag = ""; //$NON-NLS-1$
if (needTag != 0) {
if (needTag == '"') {
keyword = needTag + keyword;
endTag = String.valueOf(needTag);
} else if (needTag == '<') {
if (elementCanHaveChildren(choice)) {
endTag = String.format("></%1$s>", keyword); //$NON-NLS-1$
} else {
endTag = "/>"; //$NON-NLS-1$
}
keyword = needTag + keyword + ' ';
} else if (needTag == ' ') {
keyword = needTag + keyword;
}
} else if (!isAttribute && isNew) {
if (elementCanHaveChildren(choice)) {
endTag = String.format("></%1$s>", keyword); //$NON-NLS-1$
} else {
endTag = "/>"; //$NON-NLS-1$
}
keyword = keyword + ' ';
}
final String suffix;
int cursorPosition;
final String displayString;
if (choice instanceof AttributeDescriptor && isNew) {
// Special case for attributes: insert ="" stuff and locate caret inside ""
suffix = "=\"\""; //$NON-NLS-1$
cursorPosition = keyword.length() + suffix.length() - 1;
displayString = keyword + endTag; // don't include suffix;
} else {
suffix = endTag;
cursorPosition = keyword.length();
displayString = null;
}
if (skipEndTag) {
assert isAttribute;
cursorPosition++;
}
if (nsPrefix != null &&
keyword.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX, nsPrefix.length())) {
haveLayoutParams = true;
}
// For attributes, automatically insert ns:attribute="" and place the cursor
// inside the quotes.
// Special case for attributes: insert ="" stuff and locate caret inside ""
proposals.add(new CompletionProposal(
this,
choice,
keyword + suffix, // String replacementString
offset - wordPrefix.length(), // int replacementOffset
wordPrefix.length() + replaceLength,// int replacementLength
cursorPosition, // cursorPosition
icon, // Image image
displayString, // displayString
null, // IContextInformation contextInformation
tooltip, // String additionalProposalInfo
nsPrefix,
nsUri
));
}
}
if (wordPrefix.length() > 0 && haveLayoutParams
&& !wordPrefix.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX)) {
// Sort layout parameters to the front if we automatically inserted some
// that you didn't request. For example, you typed "width" and we match both
// "width" and "layout_width" - should match layout_width.
String nsPrefix = nsUriMap.get(ANDROID_URI);
if (nsPrefix == null) {
nsPrefix = PREFIX_ANDROID;
} else {
nsPrefix += ':';
}
if (!(wordPrefix.startsWith(nsPrefix)
&& wordPrefix.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX, nsPrefix.length()))) {
int nextLayoutIndex = 0;
for (int i = 0, n = proposals.size(); i < n; i++) {
ICompletionProposal proposal = proposals.get(i);
String keyword = proposal.getDisplayString();
if (keyword.startsWith(nsPrefix) &&
keyword.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX, nsPrefix.length())
&& i != nextLayoutIndex) {
// Swap to front
ICompletionProposal temp = proposals.get(nextLayoutIndex);
proposals.set(nextLayoutIndex, proposal);
proposals.set(i, temp);
nextLayoutIndex++;
}
}
}
}
}
/**
* Returns true if the given word starts with the given prefix. The comparison is not
* case sensitive.
*
* @param word the word to test
* @param prefix the prefix the word should start with
* @return true if the given word starts with the given prefix
*/
protected static boolean startsWith(String word, String prefix) {
int prefixLength = prefix.length();
int wordLength = word.length();
if (wordLength < prefixLength) {
return false;
}
for (int i = 0; i < prefixLength; i++) {
if (Character.toLowerCase(prefix.charAt(i))
!= Character.toLowerCase(word.charAt(i))) {
return false;
}
}
return true;
}
/** @return the editor associated with this content assist */
AndroidXmlEditor getEditor() {
return mEditor;
}
/**
* This method performs a prefix match for the given word and prefix, with a couple of
* Android code completion specific twists:
* <ol>
* <li> The match is not case sensitive, so {word="fOo",prefix="FoO"} is a match.
* <li>If the word to be matched has a namespace prefix, the typed prefix doesn't have
* to match it. So {word="android:foo", prefix="foo"} is a match.
* <li>If the attribute name part starts with "layout_" it can be omitted. So
* {word="android:layout_marginTop",prefix="margin"} is a match, as is
* {word="android:layout_marginTop",prefix="android:margin"}.
* </ol>
*
* @param word the full word to be matched, including namespace if any
* @param prefix the prefix to check
* @param nsPrefix the namespace prefix (android: or local definition of android
* namespace prefix)
* @return true if the prefix matches for code completion
*/
protected static boolean nameStartsWith(String word, String prefix, String nsPrefix) {
if (nsPrefix == null) {
nsPrefix = ""; //$NON-NLS-1$
}
int wordStart = nsPrefix.length();
int prefixStart = 0;
if (startsWith(prefix, nsPrefix)) {
// Already matches up through the namespace prefix:
prefixStart = wordStart;
} else if (startsWith(nsPrefix, prefix)) {
return true;
}
int prefixLength = prefix.length();
int wordLength = word.length();
if (wordLength - wordStart < prefixLength - prefixStart) {
return false;
}
boolean matches = true;
for (int i = prefixStart, j = wordStart; i < prefixLength; i++, j++) {
char c1 = Character.toLowerCase(prefix.charAt(i));
char c2 = Character.toLowerCase(word.charAt(j));
if (c1 != c2) {
matches = false;
break;
}
}
if (!matches && word.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX, wordStart)
&& !prefix.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX, prefixStart)) {
wordStart += ATTR_LAYOUT_RESOURCE_PREFIX.length();
if (wordLength - wordStart < prefixLength - prefixStart) {
return false;
}
for (int i = prefixStart, j = wordStart; i < prefixLength; i++, j++) {
char c1 = Character.toLowerCase(prefix.charAt(i));
char c2 = Character.toLowerCase(word.charAt(j));
if (c1 != c2) {
return false;
}
}
return true;
}
return matches;
}
/**
* Indicates whether this descriptor describes an element that can potentially
* have children (either sub-elements or text value). If an element can have children,
* we want to explicitly write an opening and a separate closing tag.
* <p/>
* Elements can have children if the descriptor has children element descriptors
* or if one of the attributes is a TextValueDescriptor.
*
* @param descriptor An ElementDescriptor or an AttributeDescriptor
* @return True if the descriptor is an ElementDescriptor that can have children or a text
* value
*/
private boolean elementCanHaveChildren(Object descriptor) {
if (descriptor instanceof ElementDescriptor) {
ElementDescriptor desc = (ElementDescriptor) descriptor;
if (desc.hasChildren()) {
return true;
}
for (AttributeDescriptor attrDesc : desc.getAttributes()) {
if (attrDesc instanceof TextValueDescriptor) {
return true;
}
}
}
return false;
}
/**
* Returns the element descriptor matching a given XML node name or null if it can't be
* found.
* <p/>
* This is simplistic; ideally we should consider the parent's chain to make sure we
* can differentiate between different hierarchy trees. Right now the first match found
* is returned.
*/
private ElementDescriptor getDescriptor(String nodeName) {
return getRootDescriptor().findChildrenDescriptor(nodeName, true /* recursive */);
}
@Override
public IContextInformation[] computeContextInformation(ITextViewer viewer, int offset) {
return null;
}
/**
* Returns the characters which when entered by the user should
* automatically trigger the presentation of possible completions.
*
* In our case, we auto-activate on opening tags and attributes namespace.
*
* @return the auto activation characters for completion proposal or <code>null</code>
* if no auto activation is desired
*/
@Override
public char[] getCompletionProposalAutoActivationCharacters() {
return new char[]{ '<', ':', '=' };
}
@Override
public char[] getContextInformationAutoActivationCharacters() {
return null;
}
@Override
public IContextInformationValidator getContextInformationValidator() {
return null;
}
@Override
public String getErrorMessage() {
return null;
}
/**
* Heuristically extracts the prefix used for determining template relevance
* from the viewer's document. The default implementation returns the String from
* offset backwards that forms a potential XML element name, attribute name or
* attribute value.
*
* The part were we access the document was extracted from
* org.eclipse.jface.text.templatesTemplateCompletionProcessor and adapted to our needs.
*
* @param viewer the viewer
* @param offset offset into document
* @return the prefix to consider
*/
protected String extractElementPrefix(ITextViewer viewer, int offset) {
int i = offset;
IDocument document = viewer.getDocument();
if (i > document.getLength()) return ""; //$NON-NLS-1$
try {
for (; i > 0; --i) {
char ch = document.getChar(i - 1);
// We want all characters that can form a valid:
// - element name, e.g. anything that is a valid Java class/variable literal.
// - attribute name, including : for the namespace
// - attribute value.
// Before we were inclusive and that made the code fragile. So now we're
// going to be exclusive: take everything till we get one of:
// - any form of whitespace
// - any xml separator, e.g. < > ' " and =
if (Character.isWhitespace(ch) ||
ch == '<' || ch == '>' || ch == '\'' || ch == '"' || ch == '=') {
break;
}
}
return document.get(i, offset - i);
} catch (BadLocationException e) {
return ""; //$NON-NLS-1$
}
}
/**
* Extracts the character at the given offset.
* Returns 0 if the offset is invalid.
*/
protected char extractChar(ITextViewer viewer, int offset) {
IDocument document = viewer.getDocument();
if (offset > document.getLength()) return 0;
try {
return document.getChar(offset);
} catch (BadLocationException e) {
return 0;
}
}
/**
* Search forward and find the first non-space character and return it. Returns 0 if no
* such character was found.
*/
private char nextNonspaceChar(ITextViewer viewer, int offset) {
IDocument document = viewer.getDocument();
int length = document.getLength();
for (; offset < length; offset++) {
try {
char c = document.getChar(offset);
if (!Character.isWhitespace(c)) {
return c;
}
} catch (BadLocationException e) {
return 0;
}
}
return 0;
}
/**
* Information about the current edit of an attribute as reported by parseAttributeInfo.
*/
protected static class AttribInfo {
public AttribInfo() {
}
/** True if the cursor is located in an attribute's value, false if in an attribute name */
public boolean isInValue = false;
/** The attribute name. Null when not set. */
public String name = null;
/** The attribute value top the left of the cursor. Null when not set. The value
* *may* start with a quote (' or "), in which case we know we don't need to quote
* the string for the user */
public String valuePrefix = null;
/** String typed by the user so far (i.e. right before requesting code completion),
* which will be corrected if we find a possible completion for an attribute value.
* See the long comment in getChoicesForAttribute(). */
public String correctedPrefix = null;
/** Non-zero if an attribute value need a start/end tag (i.e. quotes or brackets) */
public char needTag = 0;
/** Number of characters to replace after the prefix */
public int replaceLength = 0;
/** Should the cursor advance through the end tag when inserted? */
public boolean skipEndTag = false;
}
/**
* Try to guess if the cursor is editing an element's name or an attribute following an
* element. If it's an attribute, try to find if an attribute name is being defined or
* its value.
* <br/>
* This is currently *only* called when we know the cursor is after a complete element
* tag name, so it should never return null.
* <br/>
* Reference for XML syntax: http://www.w3.org/TR/2006/REC-xml-20060816/#sec-starttags
* <br/>
* @return An AttribInfo describing which attribute is being edited or null if the cursor is
* not editing an attribute (in which case it must be an element's name).
*/
private AttribInfo parseAttributeInfo(ITextViewer viewer, int offset, int prefixStartOffset) {
AttribInfo info = new AttribInfo();
int originalOffset = offset;
IDocument document = viewer.getDocument();
int n = document.getLength();
if (offset <= n) {
try {
// Look to the right to make sure we aren't sitting on the boundary of the
// beginning of a new element with whitespace before it
if (offset < n && document.getChar(offset) == '<') {
return null;
}
n = offset;
for (;offset > 0; --offset) {
char ch = document.getChar(offset - 1);
if (ch == '>') break;
if (ch == '<') break;
}
// text will contain the full string of the current element,
// i.e. whatever is after the "<" to the current cursor
String text = document.get(offset, n - offset);
// Normalize whitespace to single spaces
text = sWhitespace.matcher(text).replaceAll(" "); //$NON-NLS-1$
// Remove the leading element name. By spec, it must be after the < without
// any whitespace. If there's nothing left, no attribute has been defined yet.
// Be sure to keep any whitespace after the initial word if any, as it matters.
text = sFirstElementWord.matcher(text).replaceFirst(""); //$NON-NLS-1$
// There MUST be space after the element name. If not, the cursor is still
// defining the element name.
if (!text.startsWith(" ")) { //$NON-NLS-1$
return null;
}
// Remove full attributes:
// Syntax:
// name = "..." quoted string with all but < and "
// or:
// name = '...' quoted string with all but < and '
String temp;
do {
temp = text;
text = sFirstAttribute.matcher(temp).replaceFirst(""); //$NON-NLS-1$
} while(!temp.equals(text));
IRegion lineInfo = document.getLineInformationOfOffset(originalOffset);
int lineStart = lineInfo.getOffset();
String line = document.get(lineStart, lineInfo.getLength());
int cursorColumn = originalOffset - lineStart;
int prefixLength = originalOffset - prefixStartOffset;
// Now we're left with 3 cases:
// - nothing: either there is no attribute definition or the cursor located after
// a completed attribute definition.
// - a string with no =: the user is writing an attribute name. This case can be
// merged with the previous one.
// - string with an = sign, optionally followed by a quote (' or "): the user is
// writing the value of the attribute.
int posEqual = text.indexOf('=');
if (posEqual == -1) {
info.isInValue = false;
info.name = text.trim();
// info.name is currently just the prefix of the attribute name.
// Look at the text buffer to find the complete name (since we need
// to know its bounds in order to replace it when a different attribute
// that matches this prefix is chosen)
int nameStart = cursorColumn;
for (int nameEnd = nameStart; nameEnd < line.length(); nameEnd++) {
char c = line.charAt(nameEnd);
if (!(Character.isLetter(c) || c == ':' || c == '_')) {
String nameSuffix = line.substring(nameStart, nameEnd);
info.name = text.trim() + nameSuffix;
break;
}
}
info.replaceLength = info.name.length() - prefixLength;
if (info.name.length() == 0 && originalOffset > 0) {
// Ensure that attribute names are properly separated
char prevChar = extractChar(viewer, originalOffset - 1);
if (prevChar == '"' || prevChar == '\'') {
// Ensure that the attribute is properly separated from the
// previous element
info.needTag = ' ';
}
}
info.skipEndTag = false;
} else {
info.isInValue = true;
info.name = text.substring(0, posEqual).trim();
info.valuePrefix = text.substring(posEqual + 1);
char quoteChar = '"'; // Does " or ' surround the XML value?
for (int i = posEqual + 1; i < text.length(); i++) {
if (!Character.isWhitespace(text.charAt(i))) {
quoteChar = text.charAt(i);
break;
}
}
// Must compute the complete value
int valueStart = cursorColumn;
int valueEnd = valueStart;
for (; valueEnd < line.length(); valueEnd++) {
char c = line.charAt(valueEnd);
if (c == quoteChar) {
// Make sure this isn't the *opening* quote of the value,
// which is the case if we invoke code completion with the
// caret between the = and the opening quote; in that case
// we consider it value completion, and offer items including
// the quotes, but we shouldn't bail here thinking we have found
// the end of the value.
// Look backwards to make sure we find another " before
// we find a =
boolean isFirst = false;
for (int j = valueEnd - 1; j >= 0; j--) {
char pc = line.charAt(j);
if (pc == '=') {
isFirst = true;
break;
} else if (pc == quoteChar) {
valueStart = j;
break;
}
}
if (!isFirst) {
info.skipEndTag = true;
break;
}
}
}
int valueEndOffset = valueEnd + lineStart;
info.replaceLength = valueEndOffset - (prefixStartOffset + prefixLength);
// Is the caret to the left of the value quote? If so, include it in
// the replace length.
int valueStartOffset = valueStart + lineStart;
if (valueStartOffset == prefixStartOffset && valueEnd > valueStart) {
info.replaceLength++;
}
}
return info;
} catch (BadLocationException e) {
// pass
}
}
return null;
}
/** Returns the root descriptor id to use */
protected int getRootDescriptorId() {
return mDescriptorId;
}
/**
* Computes (if needed) and returns the root descriptor.
*/
protected ElementDescriptor getRootDescriptor() {
if (mRootDescriptor == null) {
AndroidTargetData data = mEditor.getTargetData();
if (data != null) {
IDescriptorProvider descriptorProvider =
data.getDescriptorProvider(getRootDescriptorId());
if (descriptorProvider != null) {
mRootDescriptor = new ElementDescriptor("", //$NON-NLS-1$
descriptorProvider.getRootElementDescriptors());
}
}
}
return mRootDescriptor;
}
/**
* Fixed list of dimension units, along with user documentation, for use by
* {@link #completeSuffix}.
*/
private static final String[] sDimensionUnits = new String[] {
UNIT_DP,
"<b>Density-independent Pixels</b> - an abstract unit that is based on the physical "
+ "density of the screen.",
UNIT_SP,
"<b>Scale-independent Pixels</b> - this is like the dp unit, but it is also scaled by "
+ "the user's font size preference.",
UNIT_PT,
"<b>Points</b> - 1/72 of an inch based on the physical size of the screen.",
UNIT_MM,
"<b>Millimeters</b> - based on the physical size of the screen.",
UNIT_IN,
"<b>Inches</b> - based on the physical size of the screen.",
UNIT_PX,
"<b>Pixels</b> - corresponds to actual pixels on the screen. Not recommended.",
};
/**
* Fixed list of fractional units, along with user documentation, for use by
* {@link #completeSuffix}
*/
private static final String[] sFractionUnits = new String[] {
"%", //$NON-NLS-1$
"<b>Fraction</b> - a percentage of the base size",
"%p", //$NON-NLS-1$
"<b>Fraction</b> - a percentage relative to parent container",
};
/**
* Completes suffixes for applicable types (like dimensions and fractions) such that
* after a dimension number you get completion on unit types like "px".
*/
private Object[] completeSuffix(Object[] choices, String value, UiAttributeNode currAttrNode) {
IAttributeInfo attributeInfo = currAttrNode.getDescriptor().getAttributeInfo();
EnumSet<Format> formats = attributeInfo.getFormats();
List<Object> suffixes = new ArrayList<Object>();
if (value.length() > 0 && Character.isDigit(value.charAt(0))) {
boolean hasDimension = formats.contains(Format.DIMENSION);
boolean hasFraction = formats.contains(Format.FRACTION);
if (hasDimension || hasFraction) {
// Split up the value into a numeric part (the prefix) and the
// unit part (the suffix)
int suffixBegin = 0;
for (; suffixBegin < value.length(); suffixBegin++) {
if (!Character.isDigit(value.charAt(suffixBegin))) {
break;
}
}
String number = value.substring(0, suffixBegin);
String suffix = value.substring(suffixBegin);
// Add in the matching dimension and/or fraction units, if any
if (hasDimension) {
// Each item has two entries in the array of strings: the first odd numbered
// ones are the unit names and the second even numbered ones are the
// corresponding descriptions.
for (int i = 0; i < sDimensionUnits.length; i += 2) {
String unit = sDimensionUnits[i];
if (startsWith(unit, suffix)) {
String description = sDimensionUnits[i + 1];
suffixes.add(Pair.of(number + unit, description));
}
}
// Allow "dip" completion but don't offer it ("dp" is preferred)
if (startsWith(suffix, "di") || startsWith(suffix, "dip")) { //$NON-NLS-1$ //$NON-NLS-2$
suffixes.add(Pair.of(number + "dip", "Alternative name for \"dp\"")); //$NON-NLS-1$
}
}
if (hasFraction) {
for (int i = 0; i < sFractionUnits.length; i += 2) {
String unit = sFractionUnits[i];
if (startsWith(unit, suffix)) {
String description = sFractionUnits[i + 1];
suffixes.add(Pair.of(number + unit, description));
}
}
}
}
}
boolean hasFlag = formats.contains(Format.FLAG);
if (hasFlag) {
boolean isDone = false;
String[] flagValues = attributeInfo.getFlagValues();
for (String flagValue : flagValues) {
if (flagValue.equals(value)) {
isDone = true;
break;
}
}
if (isDone) {
// Add in all the new values with a separator of |
String currentValue = currAttrNode.getCurrentValue();
for (String flagValue : flagValues) {
if (currentValue == null || !currentValue.contains(flagValue)) {
suffixes.add(value + '|' + flagValue);
}
}
}
}
if (suffixes.size() > 0) {
// Merge previously added choices (from attribute enums etc) with the new matches
List<Object> all = new ArrayList<Object>();
if (choices != null) {
for (Object s : choices) {
all.add(s);
}
}
all.addAll(suffixes);
choices = all.toArray();
}
return choices;
}
}