/* * Copyright (C) 2011 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.formatting; import static com.android.SdkConstants.ANDROID_MANIFEST_XML; import static com.android.ide.eclipse.adt.internal.editors.AndroidXmlAutoEditStrategy.findLineStart; import static com.android.ide.eclipse.adt.internal.editors.AndroidXmlAutoEditStrategy.findTextStart; import static com.android.ide.eclipse.adt.internal.editors.color.ColorDescriptors.SELECTOR_TAG; import static org.eclipse.jface.text.formatter.FormattingContextProperties.CONTEXT_MEDIUM; import static org.eclipse.jface.text.formatter.FormattingContextProperties.CONTEXT_PARTITION; import static org.eclipse.jface.text.formatter.FormattingContextProperties.CONTEXT_REGION; import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_EMPTY_TAG_CLOSE; import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_END_TAG_OPEN; import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_TAG_CLOSE; import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_TAG_OPEN; import com.android.SdkConstants; import com.android.annotations.VisibleForTesting; import com.android.ide.eclipse.adt.AdtPlugin; import com.android.ide.eclipse.adt.AdtUtils; import com.android.ide.eclipse.adt.internal.editors.layout.gle2.DomUtilities; import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; import com.android.ide.eclipse.adt.internal.project.BaseProjectHelper; import com.android.resources.ResourceType; import org.eclipse.core.resources.IProject; import org.eclipse.core.resources.IResource; import org.eclipse.core.resources.IWorkspace; import org.eclipse.core.resources.IWorkspaceRoot; import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.IDocument; import org.eclipse.jface.text.IRegion; import org.eclipse.jface.text.TextUtilities; import org.eclipse.jface.text.TypedPosition; import org.eclipse.jface.text.formatter.ContextBasedFormattingStrategy; import org.eclipse.jface.text.formatter.IFormattingContext; import org.eclipse.text.edits.MultiTextEdit; import org.eclipse.text.edits.ReplaceEdit; import org.eclipse.text.edits.TextEdit; import org.eclipse.ui.texteditor.ITextEditor; import org.eclipse.wst.sse.core.StructuredModelManager; import org.eclipse.wst.sse.core.internal.provisional.IModelManager; import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel; import org.eclipse.wst.sse.core.internal.provisional.IndexedRegion; import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument; import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocumentRegion; import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegion; import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegionList; import org.eclipse.wst.xml.core.internal.provisional.document.IDOMModel; import org.eclipse.wst.xml.core.internal.provisional.document.IDOMNode; import org.eclipse.wst.xml.ui.internal.XMLFormattingStrategy; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.w3c.dom.Text; import java.util.HashMap; import java.util.LinkedList; import java.util.Map; import java.util.Queue; /** * Formatter which formats XML content according to the established Android coding * conventions. It performs the format by computing the smallest set of DOM nodes * overlapping the formatted region, then it pretty-prints that XML region * using the {@link XmlPrettyPrinter}, and then it replaces the affected region * by the pretty-printed region. * <p> * This strategy is also used for delegation. If the user has chosen to use the * standard Eclipse XML formatter, this strategy simply delegates to the * default XML formatting strategy in WTP. */ @SuppressWarnings("restriction") public class AndroidXmlFormattingStrategy extends ContextBasedFormattingStrategy { private IRegion mRegion; private final Queue<IDocument> mDocuments = new LinkedList<IDocument>(); private final LinkedList<TypedPosition> mPartitions = new LinkedList<TypedPosition>(); private ContextBasedFormattingStrategy mDelegate = null; /** False if document is known not to be in an Android project, null until initialized */ private Boolean mIsAndroid; /** * Creates a new {@link AndroidXmlFormattingStrategy} */ public AndroidXmlFormattingStrategy() { } private ContextBasedFormattingStrategy getDelegate() { if (!AdtPrefs.getPrefs().getUseCustomXmlFormatter() || mIsAndroid != null && !mIsAndroid.booleanValue()) { if (mDelegate == null) { mDelegate = new XMLFormattingStrategy(); } return mDelegate; } return null; } @Override public void format() { // Use Eclipse XML formatter instead? ContextBasedFormattingStrategy delegate = getDelegate(); if (delegate != null) { delegate.format(); return; } super.format(); IDocument document = mDocuments.poll(); TypedPosition partition = mPartitions.poll(); if (document != null && partition != null && mRegion != null) { try { if (document instanceof IStructuredDocument) { IStructuredDocument structuredDocument = (IStructuredDocument) document; IModelManager modelManager = StructuredModelManager.getModelManager(); IStructuredModel model = modelManager.getModelForEdit(structuredDocument); if (model != null) { try { TextEdit edit = format(model, mRegion.getOffset(), mRegion.getLength()); if (edit != null) { try { model.aboutToChangeModel(); edit.apply(document); } finally { model.changedModel(); } } } finally { model.releaseFromEdit(); } } } } catch (BadLocationException e) { AdtPlugin.log(e, "Formatting error"); } } } /** * Creates a {@link TextEdit} for formatting the given model's XML in the text range * starting at offset start with the given length. Note that the exact formatting * offsets may be adjusted to format a complete element. * * @param model the model to be formatted * @param start the starting offset * @param length the length of the text range to be formatted * @return a {@link TextEdit} which edits the model into a formatted document */ private static TextEdit format(IStructuredModel model, int start, int length) { int end = start + length; TextEdit edit = new MultiTextEdit(); IStructuredDocument document = model.getStructuredDocument(); Node startNode = null; Node endNode = null; Document domDocument = null; if (model instanceof IDOMModel) { IDOMModel domModel = (IDOMModel) model; domDocument = domModel.getDocument(); } else { // This should not happen return edit; } IStructuredDocumentRegion startRegion = document.getRegionAtCharacterOffset(start); if (startRegion != null) { int startOffset = startRegion.getStartOffset(); IndexedRegion currentIndexedRegion = model.getIndexedRegion(startOffset); if (currentIndexedRegion instanceof IDOMNode) { IDOMNode currentDOMNode = (IDOMNode) currentIndexedRegion; startNode = currentDOMNode; } } boolean isOpenTagOnly = false; int openTagEnd = -1; IStructuredDocumentRegion endRegion = document.getRegionAtCharacterOffset(end); if (endRegion != null) { int endOffset = Math.max(endRegion.getStartOffset(), endRegion.getEndOffset() - 1); IndexedRegion currentIndexedRegion = model.getIndexedRegion(endOffset); // If you place the caret right on the right edge of an element, such as this: // <foo name="value">| // then the DOM model will consider the region containing the caret to be // whatever nodes FOLLOWS the element, usually a text node. // Detect this case, and look into the previous range. if (currentIndexedRegion instanceof Text && currentIndexedRegion.getStartOffset() == end && end > 0) { end--; currentIndexedRegion = model.getIndexedRegion(end); endRegion = document.getRegionAtCharacterOffset( currentIndexedRegion.getStartOffset()); } if (currentIndexedRegion instanceof IDOMNode) { IDOMNode currentDOMNode = (IDOMNode) currentIndexedRegion; endNode = currentDOMNode; // See if this range is fully within the opening tag if (endNode == startNode && endRegion == startRegion) { ITextRegion subRegion = endRegion.getRegionAtCharacterOffset(end); ITextRegionList regions = endRegion.getRegions(); int index = regions.indexOf(subRegion); if (index != -1) { // Skip past initial occurrence of close tag if we place the caret // right on a > subRegion = regions.get(index); String type = subRegion.getType(); if (type == XML_TAG_CLOSE || type == XML_EMPTY_TAG_CLOSE) { index--; } } for (; index >= 0; index--) { subRegion = regions.get(index); String type = subRegion.getType(); if (type == XML_TAG_OPEN) { isOpenTagOnly = true; } else if (type == XML_EMPTY_TAG_CLOSE || type == XML_TAG_CLOSE || type == XML_END_TAG_OPEN) { break; } } int max = regions.size(); for (index = Math.max(0, index); index < max; index++) { subRegion = regions.get(index); String type = subRegion.getType(); if (type == XML_EMPTY_TAG_CLOSE || type == XML_TAG_CLOSE) { openTagEnd = subRegion.getEnd() + endRegion.getStartOffset(); } } if (openTagEnd == -1) { isOpenTagOnly = false; } } } } String[] indentationLevels = null; Node root = null; int initialDepth = 0; int replaceStart; int replaceEnd; if (startNode == null || endNode == null) { // Process the entire document root = domDocument; // both document and documentElement should be <= 0 initialDepth = -1; startNode = root; endNode = root; replaceStart = 0; replaceEnd = document.getLength(); } else { root = DomUtilities.getCommonAncestor(startNode, endNode); initialDepth = root != null ? DomUtilities.getDepth(root) - 1 : 0; // Regions must be non-null since the DOM nodes are non null, but Eclipse null // analysis doesn't realize it: assert startRegion != null && endRegion != null; replaceStart = ((IndexedRegion) startNode).getStartOffset(); if (isOpenTagOnly) { replaceEnd = openTagEnd; } else { replaceEnd = ((IndexedRegion) endNode).getEndOffset(); } // Look up the indentation level of the start node, if it is an element // and it starts on its own line if (startNode.getNodeType() == Node.ELEMENT_NODE) { // Measure the indentation of the start node such that we can indent // the reformatted version of the node exactly in place and it should blend // in if the surrounding content does not use the same indentation size etc. // However, it's possible for the start node to have deeper depth than other // content we're formatting, as in the following scenario for example: // <foo> // <bar/> // </foo> // <baz/> // If you select this text range, we want <foo> to be formatted at whatever // level it is, and we also need to know the indentation level to use // for </baz>. We don't measure the depth of <bar/>, a child of the start node, // since from the initial indentation level and on down we want to normalize // the output. IndentationMeasurer m = new IndentationMeasurer(startNode, endNode, document); indentationLevels = m.measure(initialDepth, root); // Wipe out any levels deeper than the start node's level // (which may not be the smallest level, e.g. where you select a child // and the end of its parent etc). // (Since we're ONLY measuring the node and its parents, you might wonder // why this is doing a full subtree traversal instead of just walking up // the parent chain and looking up the indentation for each. The reason for // this is that some of theses nodes, which have not yet been formatted, // may be sharing lines with other nodes, and we disregard indentation for // any nodes that don't start a line since the indentation may only be correct // for the first element, so therefore we look for other nodes at the same // level that do have indentation info at the front of the line. int depth = DomUtilities.getDepth(startNode) - 1; for (int i = depth + 1; i < indentationLevels.length; i++) { indentationLevels[i] = null; } } } XmlFormatStyle style = guessStyle(model, domDocument); XmlFormatPreferences prefs = XmlFormatPreferences.create(); String delimiter = TextUtilities.getDefaultLineDelimiter(document); XmlPrettyPrinter printer = new XmlPrettyPrinter(prefs, style, delimiter); if (indentationLevels != null) { printer.setIndentationLevels(indentationLevels); } StringBuilder sb = new StringBuilder(length); printer.prettyPrint(initialDepth, root, startNode, endNode, sb, isOpenTagOnly); String formatted = sb.toString(); ReplaceEdit replaceEdit = createReplaceEdit(document, replaceStart, replaceEnd, formatted, prefs); if (replaceEdit != null) { edit.addChild(replaceEdit); } // Attempt to fix the selection range since otherwise, with the document shifting // under it, you end up selecting a "random" portion of text now shifted into the // old positions of the formatted text: if (replaceEdit != null && replaceStart != 0 && replaceEnd != document.getLength()) { ITextEditor editor = AdtUtils.getActiveTextEditor(); if (editor != null) { editor.setHighlightRange(replaceEdit.getOffset(), replaceEdit.getText().length(), false /*moveCursor*/); } } return edit; } /** * Create a {@link ReplaceEdit} which replaces the text in the given document with the * given new formatted content. The replaceStart and replaceEnd parameters point to * the equivalent unformatted text in the document, but the actual edit range may be * adjusted (for example to make the edit smaller if the beginning and/or end is * identical, and so on) */ @VisibleForTesting static ReplaceEdit createReplaceEdit(IDocument document, int replaceStart, int replaceEnd, String formatted, XmlFormatPreferences prefs) { // If replacing a node somewhere in the middle, start the replacement at the // beginning of the current line int index = replaceStart; try { while (index > 0) { char c = document.getChar(index - 1); if (c == '\n') { if (index < replaceStart) { replaceStart = index; } break; } else if (!Character.isWhitespace(c)) { // The replaced node does not start on its own line; in that case, // remove the initial indentation in the reformatted element for (int i = 0; i < formatted.length(); i++) { if (!Character.isWhitespace(formatted.charAt(i))) { formatted = formatted.substring(i); break; } } break; } index--; } } catch (BadLocationException e) { AdtPlugin.log(e, null); } // If there are multiple blank lines before the insert position, collapse them down // to one int prevNewlineIndex = -1; boolean beginsWithNewline = false; for (int i = 0, n = formatted.length(); i < n; i++) { char c = formatted.charAt(i); if (c == '\n') { beginsWithNewline = true; break; } else if (!Character.isWhitespace(c)) { // \r is whitespace so is handled correctly break; } } try { for (index = replaceStart - 1; index > 0; index--) { char c = document.getChar(index); if (c == '\n') { if (prevNewlineIndex != -1) { replaceStart = prevNewlineIndex; } prevNewlineIndex = index; if (index > 0 && document.getChar(index - 1) == '\r') { prevNewlineIndex--; } } else if (!Character.isWhitespace(c)) { break; } } } catch (BadLocationException e) { AdtPlugin.log(e, null); } if (prefs.removeEmptyLines && prevNewlineIndex != -1 && beginsWithNewline) { replaceStart = prevNewlineIndex + 1; } // Search forwards too int nextNewlineIndex = -1; try { int max = document.getLength(); for (index = replaceEnd; index < max; index++) { char c = document.getChar(index); if (c == '\n') { if (nextNewlineIndex != -1) { replaceEnd = nextNewlineIndex + 1; } nextNewlineIndex = index; } else if (!Character.isWhitespace(c)) { break; } } } catch (BadLocationException e) { AdtPlugin.log(e, null); } boolean endsWithNewline = false; for (int i = formatted.length() - 1; i >= 0; i--) { char c = formatted.charAt(i); if (c == '\n') { endsWithNewline = true; break; } else if (!Character.isWhitespace(c)) { break; } } if (prefs.removeEmptyLines && nextNewlineIndex != -1 && endsWithNewline) { replaceEnd = nextNewlineIndex + 1; } // Figure out how much of the before and after strings are identical and narrow // the replacement scope boolean foundDifference = false; int firstDifference = 0; int lastDifference = formatted.length(); try { for (int i = 0, j = replaceStart; i < formatted.length() && j < replaceEnd; i++, j++) { if (formatted.charAt(i) != document.getChar(j)) { firstDifference = i; foundDifference = true; break; } } if (!foundDifference) { // No differences - the document is already formatted, nothing to do return null; } lastDifference = firstDifference + 1; for (int i = formatted.length() - 1, j = replaceEnd - 1; i > firstDifference && j > replaceStart; i--, j--) { if (formatted.charAt(i) != document.getChar(j)) { lastDifference = i + 1; break; } } } catch (BadLocationException e) { AdtPlugin.log(e, null); } replaceStart += firstDifference; replaceEnd -= (formatted.length() - lastDifference); replaceEnd = Math.max(replaceStart, replaceEnd); formatted = formatted.substring(firstDifference, lastDifference); ReplaceEdit replaceEdit = new ReplaceEdit(replaceStart, replaceEnd - replaceStart, formatted); return replaceEdit; } /** * Guess what style to use to edit the given document - layout, resource, manifest, ... ? */ static XmlFormatStyle guessStyle(IStructuredModel model, Document domDocument) { // The "layout" style is used for most XML resource file types: // layouts, color-lists and state-lists, animations, drawables, menus, etc XmlFormatStyle style = XmlFormatStyle.LAYOUT; // The "resource" style is used for most value-based XML files: // strings, dimensions, booleans, colors, integers, plurals, // integer-arrays, string-arrays, and typed-arrays Element rootElement = domDocument.getDocumentElement(); if (rootElement != null && SdkConstants.TAG_RESOURCES.equals(rootElement.getTagName())) { style = XmlFormatStyle.RESOURCE; } // Selectors are also used similar to resources if (rootElement != null && SELECTOR_TAG.equals(rootElement.getTagName())) { return XmlFormatStyle.RESOURCE; } // The "manifest" style is used for manifest files String baseLocation = model.getBaseLocation(); if (baseLocation != null) { if (baseLocation.endsWith(SdkConstants.FN_ANDROID_MANIFEST_XML)) { style = XmlFormatStyle.MANIFEST; } else { int lastSlash = baseLocation.lastIndexOf('/'); if (lastSlash != -1) { int end = baseLocation.lastIndexOf('/', lastSlash - 1); // -1 is okay String resourceFolder = baseLocation.substring(end + 1, lastSlash); String[] segments = resourceFolder.split("-"); //$NON-NLS-1$ ResourceType type = ResourceType.getEnum(segments[0]); if (type != null) { style = XmlFormatStyle.get(type); } } } } return style; } private Boolean isAndroid(IDocument document) { if (mIsAndroid == null) { // Look up the corresponding IResource for this document. This isn't // readily available, so take advantage of the structured model's base location // string and convert it to an IResource to look up its project. if (document instanceof IStructuredDocument) { IStructuredDocument structuredDocument = (IStructuredDocument) document; IModelManager modelManager = StructuredModelManager.getModelManager(); IStructuredModel model = modelManager.getModelForRead(structuredDocument); if (model != null) { String location = model.getBaseLocation(); model.releaseFromRead(); if (location != null) { if (!location.endsWith(ANDROID_MANIFEST_XML) && !location.contains("/res/")) { //$NON-NLS-1$ // See if it looks like a foreign document IWorkspace workspace = ResourcesPlugin.getWorkspace(); IWorkspaceRoot root = workspace.getRoot(); IResource member = root.findMember(location); if (member.exists()) { IProject project = member.getProject(); if (project.isAccessible() && !BaseProjectHelper.isAndroidProject(project)) { mIsAndroid = false; return false; } } } // Ignore Maven POM files even in Android projects if (location.endsWith("/pom.xml")) { //$NON-NLS-1$ mIsAndroid = false; return false; } } } } mIsAndroid = true; } return mIsAndroid.booleanValue(); } @Override public void formatterStarts(final IFormattingContext context) { // Use Eclipse XML formatter instead? ContextBasedFormattingStrategy delegate = getDelegate(); if (delegate != null) { delegate.formatterStarts(context); // We also need the super implementation because it stores items into the // map, and we can't override the getPreferences method, so we need for // this delegating strategy to supply the correct values when it is called // instead of the delegate super.formatterStarts(context); return; } super.formatterStarts(context); mRegion = (IRegion) context.getProperty(CONTEXT_REGION); TypedPosition partition = (TypedPosition) context.getProperty(CONTEXT_PARTITION); IDocument document = (IDocument) context.getProperty(CONTEXT_MEDIUM); mPartitions.offer(partition); mDocuments.offer(document); if (!isAndroid(document)) { // It's some foreign type of project: use default // formatter delegate = getDelegate(); if (delegate != null) { delegate.formatterStarts(context); } } } @Override public void formatterStops() { // Use Eclipse XML formatter instead? ContextBasedFormattingStrategy delegate = getDelegate(); if (delegate != null) { delegate.formatterStops(); // See formatterStarts for an explanation super.formatterStops(); return; } super.formatterStops(); mRegion = null; mDocuments.clear(); mPartitions.clear(); } /** * Utility class which can measure the indentation strings for various node levels in * a given node range */ static class IndentationMeasurer { private final Map<Integer, String> mDepth = new HashMap<Integer, String>(); private final Node mStartNode; private final Node mEndNode; private final IStructuredDocument mDocument; private boolean mDone = false; private boolean mInRange = false; private int mMaxDepth; public IndentationMeasurer(Node mStartNode, Node mEndNode, IStructuredDocument document) { super(); this.mStartNode = mStartNode; this.mEndNode = mEndNode; mDocument = document; } /** * Measure the various depths found in the range (defined in the constructor) * under the given node which should be a common ancestor of the start and end * nodes. The result is a string array where each index corresponds to a depth, * and the string is either empty, or the complete indentation string to be used * to indent to the given depth (note that these strings are not cumulative) * * @param initialDepth the initial depth to use when visiting * @param root the root node to look for depths under * @return a string array containing nulls or indentation strings */ public String[] measure(int initialDepth, Node root) { visit(initialDepth, root); String[] indentationLevels = new String[mMaxDepth + 1]; for (Map.Entry<Integer, String> entry : mDepth.entrySet()) { int depth = entry.getKey(); String indentation = entry.getValue(); indentationLevels[depth] = indentation; } return indentationLevels; } private void visit(int depth, Node node) { // Look up indentation for this level if (node.getNodeType() == Node.ELEMENT_NODE && mDepth.get(depth) == null) { // Look up the depth try { IndexedRegion region = (IndexedRegion) node; int lineStart = findLineStart(mDocument, region.getStartOffset()); int textStart = findTextStart(mDocument, lineStart, region.getEndOffset()); // Ensure that the text which begins the line is this element, otherwise // we could be measuring the indentation of a parent element which begins // the line if (textStart == region.getStartOffset()) { String indent = mDocument.get(lineStart, Math.max(0, textStart - lineStart)); mDepth.put(depth, indent); if (depth > mMaxDepth) { mMaxDepth = depth; } } } catch (BadLocationException e) { AdtPlugin.log(e, null); } } NodeList children = node.getChildNodes(); for (int i = 0, n = children.getLength(); i < n; i++) { Node child = children.item(i); visit(depth + 1, child); if (mDone) { return; } } if (node == mEndNode) { mDone = true; } } } }