/* * Copyright (C) 2012 The Android Open Source Project * * 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.android.ide.common.xml; import com.android.SdkConstants; import com.android.annotations.NonNull; import com.android.annotations.Nullable; import com.android.resources.ResourceFolderType; import com.android.utils.SdkUtils; import com.android.utils.XmlUtils; import com.google.common.base.Charsets; import com.google.common.collect.Lists; import com.google.common.io.Files; import org.w3c.dom.Attr; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NamedNodeMap; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; import static com.android.SdkConstants.DOT_XML; import static com.android.SdkConstants.TAG_COLOR; import static com.android.SdkConstants.TAG_DIMEN; import static com.android.SdkConstants.TAG_ITEM; import static com.android.SdkConstants.TAG_STRING; import static com.android.SdkConstants.TAG_STYLE; import static com.android.SdkConstants.XMLNS; import static com.android.utils.XmlUtils.XML_COMMENT_BEGIN; import static com.android.utils.XmlUtils.XML_COMMENT_END; import static com.android.utils.XmlUtils.XML_PROLOG; /** * Visitor which walks over the subtree of the DOM to be formatted and pretty prints * the DOM into the given {@link StringBuilder} */ public class XmlPrettyPrinter { /** The style to print the XML in */ private final XmlFormatStyle mStyle; /** Formatting preferences to use when formatting the XML */ private final XmlFormatPreferences mPrefs; /** Start node to start formatting at */ private Node mStartNode; /** Start node to stop formatting after */ private Node mEndNode; /** Whether the visitor is currently in range */ private boolean mInRange; /** Output builder */ @SuppressWarnings("StringBufferField") private StringBuilder mOut; /** String to insert for a single indentation level */ private String mIndentString; /** Line separator to use */ private String mLineSeparator; /** If true, we're only formatting an open tag */ private boolean mOpenTagOnly; /** List of indentation to use for each given depth */ private String[] mIndentationLevels; /** Whether the formatter should end the document with a newline */ private boolean mEndWithNewline; /** * Creates a new {@link XmlPrettyPrinter} * * @param prefs the preferences to format with * @param style the style to format with * @param lineSeparator the line separator to use, such as "\n" (can be null, in which * case the system default is looked up via the line.separator property) */ public XmlPrettyPrinter(XmlFormatPreferences prefs, XmlFormatStyle style, String lineSeparator) { mPrefs = prefs; mStyle = style; if (lineSeparator == null) { lineSeparator = SdkUtils.getLineSeparator(); } mLineSeparator = lineSeparator; } /** * Sets whether the document should end with a newline/ line separator * * @param endWithNewline if true, ensure that the document ends with a newline * @return this, for constructor chaining */ public XmlPrettyPrinter setEndWithNewline(boolean endWithNewline) { mEndWithNewline = endWithNewline; return this; } /** * Sets the indentation levels to use (indentation string to use for each depth, * indexed by depth * * @param indentationLevels an array of strings to use for the various indentation * levels */ public void setIndentationLevels(String[] indentationLevels) { mIndentationLevels = indentationLevels; } @NonNull private String getLineSeparator() { return mLineSeparator; } /** * Pretty-prints the given XML document, which must be well-formed. If it is not, * the original unformatted XML document is returned * * @param xml the XML content to format * @param prefs the preferences to format with * @param style the style to format with * @param lineSeparator the line separator to use, such as "\n" (can be null, in which * case the system default is looked up via the line.separator property) * @return the formatted document (or if a parsing error occurred, returns the * unformatted document) */ @NonNull public static String prettyPrint( @NonNull String xml, @NonNull XmlFormatPreferences prefs, @NonNull XmlFormatStyle style, @Nullable String lineSeparator) { Document document = XmlUtils.parseDocumentSilently(xml, true); if (document != null) { XmlPrettyPrinter printer = new XmlPrettyPrinter(prefs, style, lineSeparator); printer.setEndWithNewline(xml.endsWith(printer.getLineSeparator())); StringBuilder sb = new StringBuilder(3 * xml.length() / 2); printer.prettyPrint(-1, document, null, null, sb, false /*openTagOnly*/); return sb.toString(); } else { // Parser error: just return the unformatted content return xml; } } /** * Pretty prints the given node * * @param node the node, usually a document, to be printed * @param prefs the formatting preferences * @param style the formatting style to use * @param lineSeparator the line separator to use, or null to use the * default * @return a formatted string * @deprecated Use {@link #prettyPrint(org.w3c.dom.Node, XmlFormatPreferences, * XmlFormatStyle, String, boolean)} instead */ @NonNull @Deprecated public static String prettyPrint( @NonNull Node node, @NonNull XmlFormatPreferences prefs, @NonNull XmlFormatStyle style, @Nullable String lineSeparator) { return prettyPrint(node, prefs, style, lineSeparator, false); } /** * Pretty prints the given node * * @param node the node, usually a document, to be printed * @param prefs the formatting preferences * @param style the formatting style to use * @param lineSeparator the line separator to use, or null to use the * default * @param endWithNewline if true, ensure that the printed output ends with a newline * @return a formatted string */ @NonNull public static String prettyPrint( @NonNull Node node, @NonNull XmlFormatPreferences prefs, @NonNull XmlFormatStyle style, @Nullable String lineSeparator, boolean endWithNewline) { XmlPrettyPrinter printer = new XmlPrettyPrinter(prefs, style, lineSeparator); printer.setEndWithNewline(endWithNewline); StringBuilder sb = new StringBuilder(1000); printer.prettyPrint(-1, node, null, null, sb, false /*openTagOnly*/); String xml = sb.toString(); if (node.getNodeType() == Node.DOCUMENT_NODE && !xml.startsWith("<?")) { //$NON-NLS-1$ xml = XML_PROLOG + xml; } return xml; } /** * Pretty prints the given node using default styles * * @param node the node, usually a document, to be printed * @return the resulting formatted string * @deprecated Use {@link #prettyPrint(org.w3c.dom.Node, boolean)} instead */ @NonNull @Deprecated public static String prettyPrint(@NonNull Node node) { return prettyPrint(node, false); } /** * Pretty prints the given node using default styles * * @param node the node, usually a document, to be printed * @param endWithNewline if true, ensure that the printed output ends with a newline * @return the resulting formatted string */ @NonNull public static String prettyPrint(@NonNull Node node, boolean endWithNewline) { return prettyPrint(node, XmlFormatPreferences.defaults(), XmlFormatStyle.get(node), SdkUtils.getLineSeparator(), endWithNewline); } /** * Start pretty-printing at the given node, which must either be the * startNode or contain it as a descendant. * * @param rootDepth the depth of the given node, used to determine indentation * @param root the node to start pretty printing from (which may not itself be * included in the start to end node range but should contain it) * @param startNode the node to start formatting at * @param endNode the node to end formatting at * @param out the {@link StringBuilder} to pretty print into * @param openTagOnly if true, only format the open tag of the startNode (and nothing * else) */ public void prettyPrint(int rootDepth, Node root, Node startNode, Node endNode, StringBuilder out, boolean openTagOnly) { if (startNode == null) { startNode = root; } if (endNode == null) { endNode = root; } assert !openTagOnly || startNode == endNode; mStartNode = startNode; mOpenTagOnly = openTagOnly; mEndNode = endNode; mOut = out; mInRange = false; mIndentString = mPrefs.getOneIndentUnit(); visitNode(rootDepth, root); if (mEndWithNewline && !endsWithLineSeparator()) { mOut.append(mLineSeparator); } } /** Visit the given node at the given depth */ private void visitNode(int depth, Node node) { if (node == mStartNode) { mInRange = true; } if (mInRange) { visitBeforeChildren(depth, node); if (mOpenTagOnly && mStartNode == node) { mInRange = false; return; } } NodeList children = node.getChildNodes(); for (int i = 0, n = children.getLength(); i < n; i++) { Node child = children.item(i); visitNode(depth + 1, child); } if (mInRange) { visitAfterChildren(depth, node); } if (node == mEndNode) { mInRange = false; } } private void visitBeforeChildren(int depth, Node node) { short type = node.getNodeType(); switch (type) { case Node.DOCUMENT_NODE: case Node.DOCUMENT_FRAGMENT_NODE: // Nothing to do break; case Node.ATTRIBUTE_NODE: // Handled as part of processing elements break; case Node.ELEMENT_NODE: { printOpenElementTag(depth, node); break; } case Node.TEXT_NODE: { printText(node); break; } case Node.CDATA_SECTION_NODE: printCharacterData(node); break; case Node.PROCESSING_INSTRUCTION_NODE: printProcessingInstruction(node); break; case Node.COMMENT_NODE: { printComment(depth, node); break; } case Node.DOCUMENT_TYPE_NODE: printDocType(node); break; case Node.ENTITY_REFERENCE_NODE: case Node.ENTITY_NODE: case Node.NOTATION_NODE: break; default: assert false : type; } } private void visitAfterChildren(int depth, Node node) { short type = node.getNodeType(); switch (type) { case Node.ATTRIBUTE_NODE: // Handled as part of processing elements break; case Node.ELEMENT_NODE: { printCloseElementTag(depth, node); break; } } } private void printProcessingInstruction(Node node) { mOut.append("<?xml "); //$NON-NLS-1$ mOut.append(node.getNodeValue().trim()); mOut.append('?').append('>').append(mLineSeparator); } @Nullable @SuppressWarnings("MethodMayBeStatic") // Intentionally instance method so it can be overridden protected String getSource(@NonNull Node node) { return null; } private void printDocType(Node node) { String content = getSource(node); if (content != null) { mOut.append(content); mOut.append(mLineSeparator); } } private void printCharacterData(Node node) { String nodeValue = node.getNodeValue(); boolean separateLine = nodeValue.indexOf('\n') != -1; if (separateLine && !endsWithLineSeparator()) { mOut.append(mLineSeparator); } mOut.append("<![CDATA["); //$NON-NLS-1$ mOut.append(nodeValue); mOut.append("]]>"); //$NON-NLS-1$ if (separateLine) { mOut.append(mLineSeparator); } } private void printText(Node node) { boolean escape = true; String text = node.getNodeValue(); String source = getSource(node); if (source != null) { // Get the original source string. This will contain the actual entities // such as ">" instead of ">" which it gets turned into for the DOM nodes. // By operating on source we can preserve the user's entities rather than // having > for example always turned into >. text = source; escape = false; } // Most text nodes are just whitespace for formatting (which we're replacing) // so look for actual text content and extract that part out String trimmed = text.trim(); if (!trimmed.isEmpty()) { // TODO: Reformat the contents if it is too wide? // Note that we append the actual text content, NOT the trimmed content, // since the whitespace may be significant, e.g. // <string name="toast_sync_error">Sync error: <xliff:g id="error">%1$s</xliff:g>... // However, we should remove all blank lines in the prefix and suffix of the // text node, or we will end up inserting additional blank lines each time you're // formatting a text node within an outer element (which also adds spacing lines) int lastPrefixNewline = -1; for (int i = 0, n = text.length(); i < n; i++) { char c = text.charAt(i); if (c == '\n') { lastPrefixNewline = i; } else if (!Character.isWhitespace(c)) { break; } } int firstSuffixNewline = -1; for (int i = text.length() - 1; i >= 0; i--) { char c = text.charAt(i); if (c == '\n') { firstSuffixNewline = i; } else if (!Character.isWhitespace(c)) { break; } } if (lastPrefixNewline != -1 || firstSuffixNewline != -1) { boolean stripSuffix; if (firstSuffixNewline == -1) { firstSuffixNewline = text.length(); stripSuffix = false; } else { stripSuffix = true; } int stripFrom = lastPrefixNewline + 1; if (firstSuffixNewline >= stripFrom) { text = text.substring(stripFrom, firstSuffixNewline); // In markup strings we may need to preserve spacing on the left and/or // right if we're next to a markup string on the given side if (lastPrefixNewline != -1) { Node left = node.getPreviousSibling(); if (left != null && left.getNodeType() == Node.ELEMENT_NODE && isMarkupElement((Element) left)) { text = ' ' + text; } } if (stripSuffix) { Node right = node.getNextSibling(); if (right != null && right.getNodeType() == Node.ELEMENT_NODE && isMarkupElement((Element) right)) { text += ' '; } } } } if (escape) { XmlUtils.appendXmlTextValue(mOut, text); } else { // Text is already escaped mOut.append(text); } if (mStyle != XmlFormatStyle.RESOURCE) { mOut.append(mLineSeparator); } } else { // Ensure that if we're in the middle of a markup string, we preserve spacing. // In other words, "<b>first</b> <b>second</b>" - we don't want that middle // space to disappear, but we do want repeated spaces to collapse into one. Node left = node.getPreviousSibling(); Node right = node.getNextSibling(); if (left != null && right != null && left.getNodeType() == Node.ELEMENT_NODE && right.getNodeType() == Node.ELEMENT_NODE && isMarkupElement((Element)left)) { mOut.append(' '); } } } private void printComment(int depth, Node node) { String comment = node.getNodeValue(); boolean multiLine = comment.indexOf('\n') != -1; String trimmed = comment.trim(); // See if this is an "end-of-the-line" comment, e.g. it is not a multi-line // comment and it appears on the same line as an opening or closing element tag; // if so, continue to place it as a suffix comment boolean isSuffixComment = false; if (!multiLine) { Node previous = node.getPreviousSibling(); isSuffixComment = true; if (previous == null && node.getParentNode().getNodeType() == Node.DOCUMENT_NODE) { isSuffixComment = false; } while (previous != null) { short type = previous.getNodeType(); if (type == Node.COMMENT_NODE) { isSuffixComment = false; break; } else if (type == Node.TEXT_NODE) { if (previous.getNodeValue().indexOf('\n') != -1) { isSuffixComment = false; break; } } else { break; } previous = previous.getPreviousSibling(); } if (isSuffixComment) { // Remove newline added by element open tag or element close tag if (endsWithLineSeparator()) { removeLastLineSeparator(); } mOut.append(' '); } } // Put the comment on a line on its own? Only if it was separated by a blank line // in the previous version of the document. In other words, if the document // adds blank lines between comments this formatter will preserve that fact, and vice // versa for a tightly formatted document it will preserve that convention as well. if (!mPrefs.removeEmptyLines && !isSuffixComment) { Node curr = node.getPreviousSibling(); if (curr == null) { if (mOut.length() > 0 && !endsWithLineSeparator()) { mOut.append(mLineSeparator); } } else if (curr.getNodeType() == Node.TEXT_NODE) { String text = curr.getNodeValue(); // Count how many newlines we find in the trailing whitespace of the // text node int newLines = 0; for (int i = text.length() - 1; i >= 0; i--) { char c = text.charAt(i); if (Character.isWhitespace(c)) { if (c == '\n') { newLines++; if (newLines == 2) { break; } } } else { break; } } if (newLines >= 2) { mOut.append(mLineSeparator); } else if (text.trim().isEmpty() && curr.getPreviousSibling() == null) { // Comment before first child in node mOut.append(mLineSeparator); } } } // TODO: Reformat the comment text? if (!multiLine) { if (!isSuffixComment) { indent(depth); } mOut.append(XML_COMMENT_BEGIN).append(' '); mOut.append(trimmed); mOut.append(' ').append(XML_COMMENT_END); mOut.append(mLineSeparator); } else { // Strip off blank lines at the beginning and end of the comment text. // Find last newline at the beginning of the text: int index = 0; int end = comment.length(); int recentNewline = -1; while (index < end) { char c = comment.charAt(index); if (c == '\n') { recentNewline = index; } if (!Character.isWhitespace(c)) { break; } index++; } int start = recentNewline + 1; // Find last newline at the end of the text index = end - 1; recentNewline = -1; while (index > start) { char c = comment.charAt(index); if (c == '\n') { recentNewline = index; } if (!Character.isWhitespace(c)) { break; } index--; } end = recentNewline == -1 ? index + 1 : recentNewline; if (start >= end) { // It's a blank comment like <!-- \n\n--> - just clean it up if (!isSuffixComment) { indent(depth); } mOut.append(XML_COMMENT_BEGIN).append(' ').append(XML_COMMENT_END); mOut.append(mLineSeparator); return; } trimmed = comment.substring(start, end); // When stripping out prefix and suffix blank lines we might have ended up // with a single line comment again so check and format single line comments // without newlines inside the <!-- --> delimiters multiLine = trimmed.indexOf('\n') != -1; if (multiLine) { indent(depth); mOut.append(XML_COMMENT_BEGIN); mOut.append(mLineSeparator); // See if we need to add extra spacing to keep alignment. Consider a comment // like this: // <!-- Deprecated strings - Move the identifiers to this section, // and remove the actual text. --> // This String will be // " Deprecated strings - Move the identifiers to this section,\n" + // " and remove the actual text. -->" // where the left side column no longer lines up. // To fix this, we need to insert some extra whitespace into the first line // of the string; in particular, the exact number of characters that the // first line of the comment was indented with! // However, if the comment started like this: // <!-- // /** Copyright // --> // then obviously the align-indent is 0, so we only want to compute an // align indent when we don't find a newline before the content boolean startsWithNewline = false; for (int i = 0; i < start; i++) { if (comment.charAt(i) == '\n') { startsWithNewline = true; break; } } if (!startsWithNewline) { Node previous = node.getPreviousSibling(); if (previous != null && previous.getNodeType() == Node.TEXT_NODE) { String prevText = previous.getNodeValue(); int indentation = XML_COMMENT_BEGIN.length(); for (int i = prevText.length() - 1; i >= 0; i--) { char c = prevText.charAt(i); if (c == '\n') { break; } else { indentation += (c == '\t') ? mPrefs.getTabWidth() : 1; } } // See if the next line after the newline has indentation; if it doesn't, // leave things alone. This fixes a case like this: // <!-- This is the // comment block --> // such that it doesn't turn it into // <!-- // This is the // comment block // --> // In this case we instead want // <!-- // This is the // comment block // --> int minIndent = Integer.MAX_VALUE; String[] lines = trimmed.split("\n"); //$NON-NLS-1$ // Skip line 0 since we know that it doesn't start with a newline for (int i = 1; i < lines.length; i++) { int indent = 0; String line = lines[i]; for (int j = 0; j < line.length(); j++) { char c = line.charAt(j); if (!Character.isWhitespace(c)) { // Only set minIndent if there's text content on the line; // blank lines can exist in the comment without affecting // the overall minimum indentation boundary. if (indent < minIndent) { minIndent = indent; } break; } else { indent += (c == '\t') ? mPrefs.getTabWidth() : 1; } } } if (minIndent < indentation) { indentation = minIndent; // Subtract any indentation that is already present on the line String line = lines[0]; for (int j = 0; j < line.length(); j++) { char c = line.charAt(j); if (!Character.isWhitespace(c)) { break; } else { indentation -= (c == '\t') ? mPrefs.getTabWidth() : 1; } } } for (int i = 0; i < indentation; i++) { mOut.append(' '); } if (indentation < 0) { boolean prefixIsSpace = true; for (int i = 0; i < -indentation && i < trimmed.length(); i++) { if (!Character.isWhitespace(trimmed.charAt(i))) { prefixIsSpace = false; break; } } if (prefixIsSpace) { trimmed = trimmed.substring(-indentation); } } } } mOut.append(trimmed); mOut.append(mLineSeparator); indent(depth); mOut.append(XML_COMMENT_END); mOut.append(mLineSeparator); } else { mOut.append(XML_COMMENT_BEGIN).append(' '); mOut.append(trimmed); mOut.append(' ').append(XML_COMMENT_END); mOut.append(mLineSeparator); } } // Preserve whitespace after comment: See if the original document had two or // more newlines after the comment, and if so have a blank line between this // comment and the next Node next = node.getNextSibling(); if (!mPrefs.removeEmptyLines && (next != null) && (next.getNodeType() == Node.TEXT_NODE)) { String text = next.getNodeValue(); int newLinesBeforeText = 0; for (int i = 0, n = text.length(); i < n; i++) { char c = text.charAt(i); if (c == '\n') { newLinesBeforeText++; if (newLinesBeforeText == 2) { // Yes mOut.append(mLineSeparator); break; } } else if (!Character.isWhitespace(c)) { break; } } } } private boolean endsWithLineSeparator() { int separatorLength = mLineSeparator.length(); if (mOut.length() >= separatorLength) { for (int i = 0, j = mOut.length() - separatorLength; i < separatorLength; i++) { if (mOut.charAt(j) != mLineSeparator.charAt(i)) { return false; } } } return true; } private void removeLastLineSeparator() { int newLength = mOut.length() - mLineSeparator.length(); if (newLength >= 0) { mOut.setLength(newLength); } } private void printOpenElementTag(int depth, Node node) { Element element = (Element) node; if (newlineBeforeElementOpen(element, depth)) { mOut.append(mLineSeparator); } if (indentBeforeElementOpen(element, depth)) { indent(depth); } mOut.append('<').append(element.getTagName()); NamedNodeMap attributes = element.getAttributes(); int attributeCount = attributes.getLength(); if (attributeCount > 0) { // Sort the attributes List<Attr> attributeList = new ArrayList<Attr>(); for (int i = 0; i < attributeCount; i++) { attributeList.add((Attr) attributes.item(i)); } Comparator<Attr> comparator = mPrefs.getAttributeComparator(); if (comparator != null) { Collections.sort(attributeList, comparator); } // Put the single attribute on the same line as the element tag? boolean singleLine = mPrefs.oneAttributeOnFirstLine && attributeCount == 1 // In resource files we always put all the attributes (which is // usually just zero, one or two) on the same line || mStyle == XmlFormatStyle.RESOURCE; // We also place the namespace declaration on the same line as the root element, // but this doesn't also imply singleLine handling; subsequent attributes end up // on their own lines boolean indentNextAttribute; if (singleLine || (depth == 0 && XMLNS.equals(attributeList.get(0).getPrefix()))) { mOut.append(' '); indentNextAttribute = false; } else { mOut.append(mLineSeparator); indentNextAttribute = true; } Attr last = attributeList.get(attributeCount - 1); for (Attr attribute : attributeList) { if (indentNextAttribute) { indent(depth + 1); } mOut.append(attribute.getName()); mOut.append('=').append('"'); XmlUtils.appendXmlAttributeValue(mOut, attribute.getValue()); mOut.append('"'); // Don't add a newline at the last attribute line; the > should // immediately follow the last attribute if (attribute != last) { mOut.append(singleLine ? " " : mLineSeparator); //$NON-NLS-1$ indentNextAttribute = !singleLine; } } } boolean isClosed = isEmptyTag(element); // Add a space before the > or /> ? In resource files, only do this when closing the // element if (mPrefs.spaceBeforeClose && (mStyle != XmlFormatStyle.RESOURCE || isClosed) // in <selector> files etc still treat the <item> entries as in resource files && !TAG_ITEM.equals(element.getTagName()) && (isClosed || element.getAttributes().getLength() > 0)) { mOut.append(' '); } if (isClosed) { mOut.append('/'); } mOut.append('>'); if (newlineAfterElementOpen(element, depth, isClosed)) { mOut.append(mLineSeparator); } } private void printCloseElementTag(int depth, Node node) { Element element = (Element) node; if (isEmptyTag(element)) { // Empty tag: Already handled as part of opening tag return; } // Put the closing declaration on its own line - unless it's a compact // resource file format // If the element had element children, separate the end tag from them if (newlineBeforeElementClose(element, depth)) { mOut.append(mLineSeparator); } if (indentBeforeElementClose(element, depth)) { indent(depth); } mOut.append('<').append('/'); mOut.append(node.getNodeName()); mOut.append('>'); if (newlineAfterElementClose(element, depth)) { mOut.append(mLineSeparator); } } private boolean newlineBeforeElementOpen(Element element, int depth) { if (hasBlankLineAbove()) { return false; } if (mPrefs.removeEmptyLines || depth <= 0) { return false; } if (isMarkupElement(element)) { return false; } // See if this element should be separated from the previous element. // This is the case if we are not compressing whitespace (checked above), // or if we are not immediately following a comment (in which case the // newline would have been added above it), or if we are not in a formatting // style where if (mStyle == XmlFormatStyle.LAYOUT) { // In layouts we always separate elements return true; } if (mStyle == XmlFormatStyle.MANIFEST || mStyle == XmlFormatStyle.RESOURCE || mStyle == XmlFormatStyle.FILE) { Node curr = element.getPreviousSibling(); // <style> elements are traditionally separated unless it follows a comment if (TAG_STYLE.equals(element.getTagName())) { if (curr == null || curr.getNodeType() == Node.ELEMENT_NODE || (curr.getNodeType() == Node.TEXT_NODE && curr.getNodeValue().trim().isEmpty() && (curr.getPreviousSibling() == null || curr.getPreviousSibling().getNodeType() == Node.ELEMENT_NODE))) { return true; } } // In all other styles, we separate elements if they have a different tag than // the previous one (but we don't insert a newline inside tags) while (curr != null) { short nodeType = curr.getNodeType(); if (nodeType == Node.ELEMENT_NODE) { Element sibling = (Element) curr; if (!element.getTagName().equals(sibling.getTagName())) { return true; } break; } else if (nodeType == Node.TEXT_NODE) { String text = curr.getNodeValue(); if (!text.trim().isEmpty()) { break; } // If there is just whitespace, continue looking for a previous sibling } else { // Any other previous node type, such as a comment, means we don't // continue looking: this element should not be separated break; } curr = curr.getPreviousSibling(); } if (curr == null && depth <= 1) { // Insert new line inside tag if it's the first element inside the root tag return true; } return false; } return false; } private boolean indentBeforeElementOpen(Element element, int depth) { if (isMarkupElement(element)) { return false; } if (element.getParentNode().getNodeType() == Node.ELEMENT_NODE && keepElementAsSingleLine(depth - 1, (Element) element.getParentNode())) { return false; } return true; } private boolean indentBeforeElementClose(Element element, int depth) { if (isMarkupElement(element)) { return false; } char lastOutChar = mOut.charAt(mOut.length() - 1); char lastDelimiterChar = mLineSeparator.charAt(mLineSeparator.length() - 1); return lastOutChar == lastDelimiterChar; } private boolean newlineAfterElementOpen(Element element, int depth, boolean isClosed) { if (hasBlankLineAbove()) { return false; } if (isMarkupElement(element)) { return false; } // In resource files we keep the child content directly on the same // line as the element (unless it has children). in other files, separate them return isClosed || !keepElementAsSingleLine(depth, element); } private boolean newlineBeforeElementClose(Element element, int depth) { if (hasBlankLineAbove()) { return false; } if (isMarkupElement(element)) { return false; } return depth == 0 && !mPrefs.removeEmptyLines; } private boolean hasBlankLineAbove() { if (mOut.length() < 2 * mLineSeparator.length()) { return false; } return SdkUtils.endsWith(mOut, mLineSeparator) && SdkUtils.endsWith(mOut, mOut.length() - mLineSeparator.length(), mLineSeparator); } private boolean newlineAfterElementClose(Element element, int depth) { if (hasBlankLineAbove()) { return false; } if (isMarkupElement(element)) { return false; } return element.getParentNode().getNodeType() == Node.ELEMENT_NODE && !keepElementAsSingleLine(depth - 1, (Element) element.getParentNode()); } private boolean isMarkupElement(Element element) { // The documentation suggests that the allowed tags are <u>, <b> and <i>: // developer.android.com/guide/topics/resources/string-resource.html#FormattingAndStyling // However, the full set of tags accepted by Html.fromHtml is much larger. Therefore, // instead consider *any* element nested inside a <string> definition to be a markup // element. See frameworks/base/core/java/android/text/Html.java and look for // HtmlToSpannedConverter#handleStartTag. if (mStyle != XmlFormatStyle.RESOURCE) { return false; } Node curr = element.getParentNode(); while (curr != null) { if (TAG_STRING.equals(curr.getNodeName())) { return true; } curr = curr.getParentNode(); } return false; } /** * TODO: Explain why we need to do per-tag decisions on whether to keep them on the * same line or not. Show that we can't just do it by depth, or by file type. * (style versus plurals example) * @param element the element whose tag we want to check * @return true if the element is a single line tag */ private boolean isSingleLineTag(Element element) { String tag = element.getTagName(); return (tag.equals(TAG_ITEM) && mStyle == XmlFormatStyle.RESOURCE) || tag.equals(TAG_STRING) || tag.equals(TAG_DIMEN) || tag.equals(TAG_COLOR); } private boolean keepElementAsSingleLine(int depth, Element element) { if (depth == 0) { return false; } return isSingleLineTag(element) || (mStyle == XmlFormatStyle.RESOURCE && !XmlUtils.hasElementChildren(element)); } private void indent(int depth) { int i = 0; if (mIndentationLevels != null) { for (int j = Math.min(depth, mIndentationLevels.length - 1); j >= 0; j--) { String indent = mIndentationLevels[j]; if (indent != null) { mOut.append(indent); i = j; break; } } } for (; i < depth; i++) { mOut.append(mIndentString); } } /** * Returns true if the given element should be an empty tag * * @param element the element to test * @return true if this element should be an empty tag */ @SuppressWarnings("MethodMayBeStatic") // Intentionally instance method so it can be overridden protected boolean isEmptyTag(Element element) { if (element.getFirstChild() != null) { return false; } String tag = element.getTagName(); if (TAG_STRING.equals(tag)) { return false; } return true; } private static void printUsage() { System.out.println("Usage: " + XmlPrettyPrinter.class.getSimpleName() + " <options>... <files or directories...>"); System.out.println("OPTIONS:"); System.out.println("--stdout"); System.out.println("--removeEmptyLines"); System.out.println("--noAttributeOnFirstLine"); System.out.println("--noSpaceBeforeClose"); System.exit(1); } /** Command line driver */ public static void main(String[] args) { if (args.length == 0) { printUsage(); } List<File> files = Lists.newArrayList(); XmlFormatPreferences prefs = XmlFormatPreferences.defaults(); boolean stdout = false; for (String arg : args) { if (arg.startsWith("--")) { if ("--stdout".equals(arg)) { stdout = true; } else if ("--removeEmptyLines".equals(arg)) { prefs.removeEmptyLines = true; } else if ("--noAttributeOnFirstLine".equals(arg)) { prefs.oneAttributeOnFirstLine = false; } else if ("--noSpaceBeforeClose".equals(arg)) { prefs.spaceBeforeClose = false; } else { System.err.println("Unknown flag " + arg); printUsage(); } } else { File file = new File(arg).getAbsoluteFile(); if (!file.exists()) { System.err.println("Can't find file " + file); System.exit(1); } else { files.add(file); } } } for (File file : files) { formatFile(prefs, file, stdout); } System.exit(0); } private static void formatFile(@NonNull XmlFormatPreferences prefs, File file, boolean stdout) { if (file.isDirectory()) { File[] files = file.listFiles(); if (files != null) { for (File child : files) { formatFile(prefs, child, stdout); } } } else if (file.isFile() && SdkUtils.endsWithIgnoreCase(file.getName(), DOT_XML)) { XmlFormatStyle style = null; if (file.getName().equals(SdkConstants.ANDROID_MANIFEST_XML)) { style = XmlFormatStyle.MANIFEST; } else { File parent = file.getParentFile(); if (parent != null) { String parentName = parent.getName(); ResourceFolderType folderType = ResourceFolderType.getFolderType(parentName); if (folderType == ResourceFolderType.LAYOUT) { style = XmlFormatStyle.LAYOUT; } else if (folderType == ResourceFolderType.VALUES) { style = XmlFormatStyle.RESOURCE; } } } try { String xml = Files.toString(file, Charsets.UTF_8); Document document = XmlUtils.parseDocumentSilently(xml, true); if (document == null) { System.err.println("Could not parse " + file); System.exit(1); return; } if (style == null) { style = XmlFormatStyle.get(document); } boolean endWithNewline = xml.endsWith("\n"); int firstNewLine = xml.indexOf('\n'); String lineSeparator = firstNewLine > 0 && xml.charAt(firstNewLine - 1) == '\r' ? "\r\n" : "\n"; String formatted = XmlPrettyPrinter.prettyPrint(document, prefs, style, lineSeparator, endWithNewline); if (stdout) { System.out.println(formatted); } else { Files.write(formatted, file, Charsets.UTF_8); } } catch (IOException e) { System.err.println("Could not read " + file); System.exit(1); } } } }