blob: 8935e2881ecdf2cda155e1e4ef1cdfcbd0e5f6b8 [file] [log] [blame]
/*
* Copyright (C) 2011 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.manifmerger;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.annotations.VisibleForTesting;
import com.android.manifmerger.IMergerLog.FileAndLine;
import com.android.manifmerger.IMergerLog.Severity;
import com.android.utils.ILogger;
import com.android.utils.XmlUtils;
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.xml.sax.ErrorHandler;
import org.xml.sax.InputSource;
import org.xml.sax.SAXParseException;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.Reader;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
/**
* A few XML handling utilities.
*/
class MergerXmlUtils {
private static final String DATA_ORIGIN_FILE = "manif.merger.file"; //$NON-NLS-1$
private static final String DATA_FILE_NAME = "manif.merger.filename"; //$NON-NLS-1$
private static final String DATA_LINE_NUMBER = "manif.merger.line#"; //$NON-NLS-1$
/**
* Parses the given XML file as a DOM document.
* The parser does not validate the DTD nor any kind of schema.
* It is namespace aware.
* <p/>
* This adds a user tag with the original {@link File} to the returned document.
* You can retrieve this file later by using {@link #extractXmlFilename(Node)}.
*
* @param xmlFile The XML {@link File} to parse. Must not be null.
* @param log An {@link ILogger} for reporting errors. Must not be null.
* @param merger The {@link ManifestMerger} this document is intended for
* @return A new DOM {@link Document}, or null.
*/
@Nullable
static Document parseDocument(@NonNull final File xmlFile, @NonNull final IMergerLog log,
@NonNull ManifestMerger merger) {
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
Reader reader = XmlUtils.getUtfReader(xmlFile);
InputSource is = new InputSource(reader);
factory.setNamespaceAware(true);
factory.setValidating(false);
DocumentBuilder builder = factory.newDocumentBuilder();
// We don't want the default handler which prints errors to stderr.
builder.setErrorHandler(new ErrorHandler() {
@Override
public void warning(SAXParseException e) {
log.error(Severity.WARNING,
new FileAndLine(xmlFile.getAbsolutePath(), 0),
"Warning when parsing: %1$s",
e.toString());
}
@Override
public void fatalError(SAXParseException e) {
log.error(Severity.ERROR,
new FileAndLine(xmlFile.getAbsolutePath(), 0),
"Fatal error when parsing: %1$s",
xmlFile.getName(), e.toString());
}
@Override
public void error(SAXParseException e) {
log.error(Severity.ERROR,
new FileAndLine(xmlFile.getAbsolutePath(), 0),
"Error when parsing: %1$s",
e.toString());
}
});
Document doc = builder.parse(is);
doc.setUserData(DATA_ORIGIN_FILE, xmlFile, null /*handler*/);
findLineNumbers(doc, 1);
if (merger.isInsertSourceMarkers()) {
setSource(doc, xmlFile);
}
return doc;
} catch (FileNotFoundException e) {
log.error(Severity.ERROR,
new FileAndLine(xmlFile.getAbsolutePath(), 0),
"XML file not found");
} catch (Exception e) {
log.error(Severity.ERROR,
new FileAndLine(xmlFile.getAbsolutePath(), 0),
"Failed to parse XML file: %1$s",
e.toString());
}
return null;
}
/**
* Parses the given XML string as a DOM document.
* The parser does not validate the DTD nor any kind of schema.
* It is namespace aware.
*
* @param xml The XML string to parse. Must not be null.
* @param log An {@link ILogger} for reporting errors. Must not be null.
* @return A new DOM {@link Document}, or null.
*/
@VisibleForTesting
@Nullable
static Document parseDocument(@NonNull String xml,
@NonNull IMergerLog log,
@NonNull FileAndLine errorContext) {
try {
Document doc = XmlUtils.parseDocument(xml, true);
findLineNumbers(doc, 1);
if (errorContext.getFileName() != null) {
setSource(doc, new File(errorContext.getFileName()));
}
return doc;
} catch (Exception e) {
log.error(Severity.ERROR, errorContext, "Failed to parse XML string");
}
return null;
}
/**
* Decorates the document with the specified file name, which can be
* retrieved later by calling {@link #extractLineNumber(Node)}.
* <p/>
* It also tries to add line number information, with the caveat that the
* current implementation is a gross approximation.
* <p/>
* There is no need to call this after calling one of the {@code parseDocument()}
* methods since they already decorated their own document.
*
* @param doc The document to decorate.
* @param fileName The name to retrieve later for that document.
*/
static void decorateDocument(@NonNull Document doc, @NonNull String fileName) {
doc.setUserData(DATA_FILE_NAME, fileName, null /*handler*/);
findLineNumbers(doc, 1);
}
/**
* Returns a new {@link FileAndLine} structure that identifies
* the base filename & line number from which the XML node was parsed.
* <p/>
* When the line number is unknown (e.g. if a {@link Document} instance is given)
* then line number 0 will be used.
*
* @param node The node or document where the error occurs. Must not be null.
* @return A new non-null {@link FileAndLine} combining the file name and line number.
*/
@NonNull
static FileAndLine xmlFileAndLine(@NonNull Node node) {
String name = extractXmlFilename(node);
int line = extractLineNumber(node); // 0 in case of error or unknown
return new FileAndLine(name, line);
}
/**
* Extracts the origin {@link File} that {@link #parseDocument(File, IMergerLog,
* ManifestMerger)} added to the XML document or the string added by
*
* @param xmlNode Any node from a document returned by {@link #parseDocument(File, IMergerLog,
* ManifestMerger)}.
* @return The {@link File} object used to create the document or null.
*/
@Nullable
static String extractXmlFilename(@Nullable Node xmlNode) {
if (xmlNode != null && xmlNode.getNodeType() != Node.DOCUMENT_NODE) {
xmlNode = xmlNode.getOwnerDocument();
}
if (xmlNode != null) {
Object data = xmlNode.getUserData(DATA_ORIGIN_FILE);
if (data instanceof File) {
return ((File) data).getPath();
}
data = xmlNode.getUserData(DATA_FILE_NAME);
if (data instanceof String) {
return (String) data;
}
}
return null;
}
public static void setSource(@NonNull Node node, @NonNull File source) {
//noinspection ConstantConditions
for (; node != null; node = node.getNextSibling()) {
short nodeType = node.getNodeType();
if (nodeType == Node.ELEMENT_NODE
|| nodeType == Node.COMMENT_NODE
|| nodeType == Node.DOCUMENT_NODE
|| nodeType == Node.CDATA_SECTION_NODE) {
node.setUserData(DATA_ORIGIN_FILE, source, null);
}
Node child = node.getFirstChild();
setSource(child, source);
}
}
/**
* This is a CRUDE INEXACT HACK to decorate the DOM with some kind of line number
* information for elements. It's inexact because by the time we get the DOM we
* already have lost all the information about whitespace between attributes.
* <p/>
* Also we don't even try to deal with \n vs \r vs \r\n insanity. This only counts
* the \n occurring in text nodes to determine line advances, which is clearly flawed.
* <p/>
* However it's good enough for testing, and we'll replace it by a PositionXmlParser
* once it's moved into com.android.util.
*/
private static int findLineNumbers(Node node, int line) {
for (; node != null; node = node.getNextSibling()) {
node.setUserData(DATA_LINE_NUMBER, Integer.valueOf(line), null /*handler*/);
if (node.getNodeType() == Node.TEXT_NODE) {
String text = node.getNodeValue();
if (!text.isEmpty()) {
for (int pos = 0; (pos = text.indexOf('\n', pos)) != -1; pos++) {
++line;
}
}
}
Node child = node.getFirstChild();
if (child != null) {
line = findLineNumbers(child, line);
}
}
return line;
}
/**
* Extracts the line number that {@link #findLineNumbers} added to the XML nodes.
*
* @param xmlNode Any node from a document returned by {@link #parseDocument(File, IMergerLog,
* ManifestMerger)}.
* @return The line number if found or 0.
*/
static int extractLineNumber(@Nullable Node xmlNode) {
if (xmlNode != null) {
Object data = xmlNode.getUserData(DATA_LINE_NUMBER);
if (data instanceof Integer) {
return ((Integer) data).intValue();
}
}
return 0;
}
/**
* Outputs the given XML {@link Document} to the file {@code outFile}.
*
* TODO right now reformats the document. Needs to output as-is, respecting white-space.
*
* @param doc The document to output. Must not be null.
* @param outFile The {@link File} where to write the document.
* @param log A log in case of error.
* @return True if the file was written, false in case of error.
*/
static boolean printXmlFile(
@NonNull Document doc,
@NonNull File outFile,
@NonNull IMergerLog log) {
// Quick thing based on comments from http://stackoverflow.com/questions/139076
try {
Transformer tf = TransformerFactory.newInstance().newTransformer();
tf.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); //$NON-NLS-1$
tf.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); //$NON-NLS-1$
tf.setOutputProperty(OutputKeys.INDENT, "yes"); //$NON-NLS-1$
tf.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", //$NON-NLS-1$
"4"); //$NON-NLS-1$
tf.transform(new DOMSource(doc), new StreamResult(outFile));
return true;
} catch (TransformerException e) {
log.error(Severity.ERROR,
new FileAndLine(outFile.getName(), 0),
"Failed to write XML file: %1$s",
e.toString());
return false;
}
}
/**
* Outputs the given XML {@link Document} as a string.
*
* TODO right now reformats the document. Needs to output as-is, respecting white-space.
*
* @param doc The document to output. Must not be null.
* @param log A log in case of error.
* @return A string representation of the XML. Null in case of error.
*/
static String printXmlString(
@NonNull Document doc,
@NonNull IMergerLog log) {
try {
Transformer tf = TransformerFactory.newInstance().newTransformer();
tf.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); //$NON-NLS-1$
tf.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); //$NON-NLS-1$
tf.setOutputProperty(OutputKeys.INDENT, "yes"); //$NON-NLS-1$
tf.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", //$NON-NLS-1$
"4"); //$NON-NLS-1$
StringWriter sw = new StringWriter();
tf.transform(new DOMSource(doc), new StreamResult(sw));
return sw.toString();
} catch (TransformerException e) {
log.error(Severity.ERROR,
new FileAndLine(extractXmlFilename(doc), 0),
"Failed to write XML file: %1$s",
e.toString());
return null;
}
}
/**
* Dumps the structure of the DOM to a simple text string.
*
* @param node The first node to dump (recursively). Can be null.
* @param nextSiblings If true, will also dump the following siblings.
* If false, it will just process the given node.
* @return A string representation of the Node structure, useful for debugging.
*/
@NonNull
static String dump(@Nullable Node node, boolean nextSiblings) {
return dump(node, 0 /*offset*/, nextSiblings, true /*deep*/, null /*keyAttr*/);
}
/**
* Dumps the structure of the DOM to a simple text string.
* Each line is terminated with a \n separator.
*
* @param node The first node to dump. Can be null.
* @param offsetIndex The offset to add at the begining of each line. Each offset is
* converted into 2 space characters.
* @param nextSiblings If true, will also dump the following siblings.
* If false, it will just process the given node.
* @param deep If true, this will recurse into children.
* @param keyAttr An optional attribute *local* name to insert when writing an element.
* For example when writing an Activity, it helps to always insert "name" attribute.
* @return A string representation of the Node structure, useful for debugging.
*/
@NonNull
static String dump(
@Nullable Node node,
int offsetIndex,
boolean nextSiblings,
boolean deep,
@Nullable String keyAttr) {
StringBuilder sb = new StringBuilder();
String offset = ""; //$NON-NLS-1$
for (int i = 0; i < offsetIndex; i++) {
offset += " "; //$NON-NLS-1$
}
if (node == null) {
sb.append(offset).append("(end reached)\n");
} else {
for (; node != null; node = node.getNextSibling()) {
String type = null;
short t = node.getNodeType();
switch(t) {
case Node.ELEMENT_NODE:
String attr = "";
if (keyAttr != null) {
for (Node a : sortedAttributeList(node.getAttributes())) {
if (a != null && keyAttr.equals(a.getLocalName())) {
attr = String.format(" %1$s=%2$s",
a.getNodeName(), a.getNodeValue());
break;
}
}
}
sb.append(String.format("%1$s<%2$s%3$s>\n",
offset, node.getNodeName(), attr));
break;
case Node.COMMENT_NODE:
sb.append(String.format("%1$s<!-- %2$s -->\n",
offset, node.getNodeValue()));
break;
case Node.TEXT_NODE:
String txt = node.getNodeValue().trim();
if (txt.isEmpty()) {
// Keep this for debugging. TODO make it a flag
// to dump whitespace on debugging. Otherwise ignore it.
// txt = "[whitespace]";
break;
}
sb.append(String.format("%1$s%2$s\n", offset, txt));
break;
case Node.ATTRIBUTE_NODE:
sb.append(String.format("%1$s @%2$s = %3$s\n",
offset, node.getNodeName(), node.getNodeValue()));
break;
case Node.CDATA_SECTION_NODE:
type = "cdata"; //$NON-NLS-1$
break;
case Node.DOCUMENT_NODE:
type = "document"; //$NON-NLS-1$
break;
case Node.PROCESSING_INSTRUCTION_NODE:
type = "PI"; //$NON-NLS-1$
break;
default:
type = Integer.toString(t);
}
if (type != null) {
sb.append(String.format("%1$s[%2$s] <%3$s> %4$s\n",
offset, type, node.getNodeName(), node.getNodeValue()));
}
if (deep) {
for (Attr attr : sortedAttributeList(node.getAttributes())) {
sb.append(String.format("%1$s @%2$s = %3$s\n",
offset, attr.getNodeName(), attr.getNodeValue()));
}
Node child = node.getFirstChild();
if (child != null) {
sb.append(dump(child, offsetIndex+1, true, true, keyAttr));
}
}
if (!nextSiblings) {
break;
}
}
}
return sb.toString();
}
/**
* Returns a sorted list of attributes.
* The list is never null and does not contain null items.
*
* @param attrMap A Node map as returned by {@link Node#getAttributes()}.
* Can be null, in which case an empty list is returned.
* @return A non-null, possible empty, list of all nodes that are actual {@link Attr},
* sorted by increasing attribute name.
*/
@NonNull
static List<Attr> sortedAttributeList(@Nullable NamedNodeMap attrMap) {
List<Attr> list = new ArrayList<Attr>();
if (attrMap != null) {
for (int i = 0; i < attrMap.getLength(); i++) {
Node attr = attrMap.item(i);
if (attr instanceof Attr) {
list.add((Attr) attr);
}
}
}
if (list.size() > 1) {
// Sort it by attribute name
Collections.sort(list, getAttrComparator());
}
return list;
}
/**
* Returns a comparator for {@link Attr}, alphabetically sorted by name.
* The "name" attribute is special and always sorted to the front.
*/
@NonNull
static Comparator<? super Attr> getAttrComparator() {
return new Comparator<Attr>() {
@Override
public int compare(Attr a1, Attr a2) {
String s1 = a1 == null ? "" : a1.getNodeName(); //$NON-NLS-1$
String s2 = a2 == null ? "" : a2.getNodeName(); //$NON-NLS-1$
boolean name1 = s1.equals("name"); //$NON-NLS-1$
boolean name2 = s2.equals("name"); //$NON-NLS-1$
if (name1 && name2) {
return 0;
} else if (name1) {
return -1; // name is always first
} else if (name2) {
return 1; // name is always first
} else {
return s1.compareTo(s2);
}
}
};
}
/**
* Inject attributes into an existing document.
* <p/>
* The map keys are "/manifest/elements...|attribute-ns-uri attribute-local-name",
* for example "/manifest/uses-sdk|http://schemas.android.com/apk/res/android minSdkVersion".
* (note the space separator between the attribute URI and its local name.)
* The elements will be created if they don't exists. Existing attributes will be modified.
* The replacement is done on the main document <em>before</em> merging.
* The value can be null to remove an existing attribute.
*
* @param doc The document to modify in-place.
* @param attributeMap A map of attributes to inject in the form [pseudo-xpath] => value.
* @param log A log in case of error.
*/
static void injectAttributes(
@Nullable Document doc,
@Nullable Map<String, String> attributeMap,
@NonNull IMergerLog log) {
if (doc == null || attributeMap == null || attributeMap.isEmpty()) {
return;
}
// 1=path 2=URI 3=local name
final Pattern keyRx = Pattern.compile("^/([^\\|]+)\\|([^ ]*) +(.+)$"); //$NON-NLS-1$
final FileAndLine docInfo = xmlFileAndLine(doc);
nextAttribute: for (Entry<String, String> entry : attributeMap.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if (key == null || key.isEmpty()) {
continue;
}
Matcher m = keyRx.matcher(key);
if (!m.matches()) {
log.error(Severity.WARNING, docInfo, "Invalid injected attribute key: %s", key);
continue;
}
String path = m.group(1);
String attrNsUri = m.group(2);
String attrName = m.group(3);
String[] segment = path.split(Pattern.quote("/")); //$NON-NLS-1$
// Get the path elements. Create them as needed if they don't exist.
Node element = doc;
nextSegment: for (int i = 0; i < segment.length; i++) {
// Find a child with the segment's name
String name = segment[i];
for (Node child = element.getFirstChild();
child != null;
child = child.getNextSibling()) {
if (child.getNodeType() == Node.ELEMENT_NODE &&
child.getNamespaceURI() == null &&
child.getNodeName().equals(name)) {
// Found it. Continue to the next inner segment.
element = child;
continue nextSegment;
}
}
// No such element. Create it.
if (value == null) {
// If value is null, we want to remove, not create and if can't find the
// element, then we're done: there's no such attribute to remove.
break nextAttribute;
}
Element child = doc.createElement(name);
element = element.insertBefore(child, element.getFirstChild());
}
if (element == null) {
log.error(Severity.WARNING, docInfo, "Invalid injected attribute path: %s", path);
return;
}
NamedNodeMap attrs = element.getAttributes();
if (attrs != null) {
if (attrNsUri != null && attrNsUri.isEmpty()) {
attrNsUri = null;
}
Node attr = attrs.getNamedItemNS(attrNsUri, attrName);
if (value == null) {
// We want to remove the attribute from the attribute map.
if (attr != null) {
attrs.removeNamedItemNS(attrNsUri, attrName);
}
} else {
// We want to add or replace the attribute.
if (attr == null) {
attr = doc.createAttributeNS(attrNsUri, attrName);
if (attrNsUri != null) {
attr.setPrefix(XmlUtils.lookupNamespacePrefix(element, attrNsUri));
}
attrs.setNamedItemNS(attr);
}
attr.setNodeValue(value);
}
}
}
}
// -------
/**
* Flatten the element to a string. This "pretty prints" the XML tree starting
* from the given node and all its children and attributes.
* <p/>
* The output is designed to be printed using {@link #printXmlDiff}.
*
* @param node The root node to print.
* @param nsPrefix A map that is filled with all the URI=>prefix found.
* The internal string only contains the expanded URIs but this is rather verbose
* so when printing the diff these will be replaced by the prefixes collected here.
* @param prefix A "space" prefix added at the beginning of each line for indentation
* purposes. The diff printer later relies on this to find out the structure.
*/
@NonNull
static String printElement(
@NonNull Node node,
@NonNull Map<String, String> nsPrefix,
@NonNull String prefix) {
StringBuilder sb = new StringBuilder();
sb.append(prefix).append('<');
String uri = node.getNamespaceURI();
if (uri != null) {
sb.append(uri).append(':');
nsPrefix.put(uri, node.getPrefix());
}
sb.append(node.getLocalName());
printAttributes(sb, node, nsPrefix, prefix);
sb.append(">\n"); //$NON-NLS-1$
printChildren(sb, node.getFirstChild(), true, nsPrefix, prefix + " "); //$NON-NLS-1$
sb.append(prefix).append("</"); //$NON-NLS-1$
if (uri != null) {
sb.append(uri).append(':');
}
sb.append(node.getLocalName());
sb.append(">\n"); //$NON-NLS-1$
return sb.toString();
}
/**
* Flatten several children elements to a string.
* This is an implementation detail for {@link #printElement(Node, Map, String)}.
* <p/>
* If {@code nextSiblings} is false, the string conversion takes only the given
* child element and stops there.
* <p/>
* If {@code nextSiblings} is true, the string conversion also takes _all_ the siblings
* after the given element. The idea is the caller can call this with the first child
* of a parent and get a string showing all the children at the same time. They are
* sorted to avoid the ordering issue.
*/
@NonNull
private static StringBuilder printChildren(
@NonNull StringBuilder sb,
@NonNull Node child,
boolean nextSiblings,
@NonNull Map<String, String> nsPrefix,
@NonNull String prefix) {
ArrayList<String> children = new ArrayList<String>();
boolean hasText = false;
for (; child != null; child = child.getNextSibling()) {
short t = child.getNodeType();
if (nextSiblings && t == Node.TEXT_NODE) {
// We don't typically have meaningful text nodes in an Android manifest.
// If there are, just dump them as-is into the element representation.
// We do trim whitespace and ignore all-whitespace or empty text nodes.
String s = child.getNodeValue().trim();
if (!s.isEmpty()) {
sb.append(s);
hasText = true;
}
} else if (t == Node.ELEMENT_NODE) {
children.add(printElement(child, nsPrefix, prefix));
if (!nextSiblings) {
break;
}
}
}
if (hasText) {
sb.append('\n');
}
if (!children.isEmpty()) {
Collections.sort(children);
for (String s : children) {
sb.append(s);
}
}
return sb;
}
/**
* Flatten several attributes to a string using their alphabetical order.
* This is an implementation detail for {@link #printElement(Node, Map, String)}.
*/
@NonNull
private static StringBuilder printAttributes(
@NonNull StringBuilder sb,
@NonNull Node node,
@NonNull Map<String, String> nsPrefix,
@NonNull String prefix) {
ArrayList<String> attrs = new ArrayList<String>();
NamedNodeMap attrMap = node.getAttributes();
if (attrMap != null) {
StringBuilder sb2 = new StringBuilder();
for (int i = 0; i < attrMap.getLength(); i++) {
Node attr = attrMap.item(i);
if (attr instanceof Attr) {
sb2.setLength(0);
sb2.append('@');
String uri = attr.getNamespaceURI();
if (uri != null) {
sb2.append(uri).append(':');
nsPrefix.put(uri, attr.getPrefix());
}
sb2.append(attr.getLocalName());
sb2.append("=\"").append(attr.getNodeValue()).append('\"'); //$NON-NLS-1$
attrs.add(sb2.toString());
}
}
}
Collections.sort(attrs);
for(String attr : attrs) {
sb.append('\n');
sb.append(prefix).append(" ").append(attr); //$NON-NLS-1$
}
return sb;
}
//------------
/**
* Computes a quick diff between two strings generated by
* {@link #printElement(Node, Map, String)}.
* <p/>
* This is a <em>not</em> designed to be a full contextual diff.
* It just stops at the first difference found, printing up to 3 lines of diff
* and backtracking to add prior contextual information to understand the
* structure of the element where the first diff line occurred (by printing
* each parent found till the root one as well as printing the attribute
* named by {@code keyAttr}).
*
* @param sb The string builder where to output is written.
* @param expected The expected XML tree (as generated by {@link #printElement}.)
* For best result this would be the "destination" XML we're merging into,
* e.g. the main manifest.
* @param actual The actual XML tree (as generated by {@link #printElement}.)
* For best result this would be the "source" XML we're merging from,
* e.g. a library manifest.
* @param nsPrefixE The map of URI=>prefix for the expected XML tree.
* @param nsPrefixA The map of URI=>prefix for the actual XML tree.
* @param keyAttr An optional attribute *full* name (uri:local name) to always
* insert when writing the contextual lines before a diff line.
* For example when writing an Activity, it helps to always insert
* the "name" attribute since that's the key element to help the user
* identify which node is being dumped.
*/
static void printXmlDiff(
StringBuilder sb,
String expected,
String actual,
Map<String, String> nsPrefixE,
Map<String, String> nsPrefixA,
String keyAttr) {
String[] aE = expected.split("\n");
String[] aA = actual.split("\n");
int lE = aE.length;
int lA = aA.length;
int lm = lE < lA ? lA : lE;
boolean eofE = false;
boolean eofA = false;
boolean contextE = true;
boolean contextA = true;
int numDiff = 0;
StringBuilder sE = new StringBuilder();
StringBuilder sA = new StringBuilder();
outerLoop: for (int i = 0, iE = 0, iA = 0; i < lm; i++) {
if (iE < lE && iA < lA && aE[iE].equals(aA[iA])) {
if (numDiff > 0) {
// If we found a difference, stop now.
break outerLoop;
}
iE++;
iA++;
continue;
} else {
// Try to print some context for each side based on previous lines's space prefix.
if (contextE) {
if (iE > 0) {
String p = diffGetPrefix(aE[iE]);
for (int kE = iE-1; kE >= 0; kE--) {
if (!aE[kE].startsWith(p)) {
sE.insert(0, '\n').insert(0, diffReplaceNs(aE[kE], nsPrefixE)).insert(0, " ");
if (p.isEmpty()) {
break;
}
p = diffGetPrefix(aE[kE]);
} else if (aE[kE].contains(keyAttr) || kE == 0) {
sE.insert(0, '\n').insert(0, diffReplaceNs(aE[kE], nsPrefixE)).insert(0, " ");
}
}
}
contextE = false;
}
if (iE >= lE) {
if (!eofE) {
sE.append("--(end reached)\n");
eofE = true;
}
} else {
sE.append("--").append(diffReplaceNs(aE[iE++], nsPrefixE)).append('\n');
}
if (contextA) {
if (iA > 0) {
String p = diffGetPrefix(aA[iA]);
for (int kA = iA-1; kA >= 0; kA--) {
if (!aA[kA].startsWith(p)) {
sA.insert(0, '\n').insert(0, diffReplaceNs(aA[kA], nsPrefixA)).insert(0, " ");
p = diffGetPrefix(aA[kA]);
if (p.isEmpty()) {
break;
}
} else if (aA[kA].contains(keyAttr) || kA == 0) {
sA.insert(0, '\n').insert(0, diffReplaceNs(aA[kA], nsPrefixA)).insert(0, " ");
}
}
}
contextA = false;
}
if (iA >= lA) {
if (!eofA) {
sA.append("++(end reached)\n");
eofA = true;
}
} else {
sA.append("++").append(diffReplaceNs(aA[iA++], nsPrefixA)).append('\n');
}
// Dump up to 3 lines of difference
numDiff++;
if (numDiff == 3) {
break outerLoop;
}
}
}
sb.append(sE);
sb.append(sA);
}
/**
* Returns all the whitespace at the beginning of a string.
* Implementation details for {@link #printXmlDiff} used to find the "parent"
* element and include it in the context of the diff.
*/
private static String diffGetPrefix(String str) {
int pos = 0;
int len = str.length();
while (pos < len && str.charAt(pos) == ' ') {
pos++;
}
return str.substring(0, pos);
}
/**
* Simplifies a diff line by replacing NS URIs by their prefix.
* Implementation details for {@link #printXmlDiff}.
*/
private static String diffReplaceNs(String str, Map<String, String> nsPrefix) {
for (Entry<String, String> entry : nsPrefix.entrySet()) {
String uri = entry.getKey();
String prefix = entry.getValue();
if (prefix != null && str.contains(uri)) {
str = str.replaceAll(Pattern.quote(uri), Matcher.quoteReplacement(prefix));
}
}
return str;
}
/**
* Returns the file associated with the given specific node, if any.
* Note that this will not search upwards for parent nodes; it returns a
* file associated with this specific node, if any.
*/
@Nullable
public static File getFileFor(@NonNull Node node) {
return (File) node.getUserData(DATA_ORIGIN_FILE);
}
/**
* Sets the file associated with the given node, if any
*/
public static void setFileFor(Node node, File file) {
node.setUserData(MergerXmlUtils.DATA_ORIGIN_FILE, file, null);
}
}