/*******************************************************************************
* Copyright (c) 2012-2017 Codenvy, S.A.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* Codenvy, S.A. - initial API and implementation
*******************************************************************************/
package org.eclipse.che.commons.xml;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.namespace.QName;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import javax.xml.xpath.XPathFactoryConfigurationException;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import static javax.xml.XMLConstants.FEATURE_SECURE_PROCESSING;
import static javax.xml.stream.XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES;
import static javax.xml.stream.XMLInputFactory.SUPPORT_DTD;
import static org.eclipse.che.commons.xml.XMLTreeUtil.SPACES_IN_TAB;
import static org.eclipse.che.commons.xml.XMLTreeUtil.UTF_8;
import static org.eclipse.che.commons.xml.XMLTreeUtil.asElement;
import static org.eclipse.che.commons.xml.XMLTreeUtil.asElements;
import static org.eclipse.che.commons.xml.XMLTreeUtil.closeTagLength;
import static org.eclipse.che.commons.xml.XMLTreeUtil.indexOf;
import static org.eclipse.che.commons.xml.XMLTreeUtil.indexOfAttributeName;
import static org.eclipse.che.commons.xml.XMLTreeUtil.replaceAll;
import static org.eclipse.che.commons.xml.XMLTreeUtil.rootStart;
import static org.eclipse.che.commons.xml.XMLTreeUtil.single;
import static org.eclipse.che.commons.xml.XMLTreeUtil.level;
import static org.eclipse.che.commons.xml.XMLTreeUtil.insertBetween;
import static org.eclipse.che.commons.xml.XMLTreeUtil.insertInto;
import static org.eclipse.che.commons.xml.XMLTreeUtil.lastIndexOf;
import static org.eclipse.che.commons.xml.XMLTreeUtil.openTagLength;
import static org.eclipse.che.commons.xml.XMLTreeUtil.tabulate;
import static com.google.common.collect.Maps.newHashMapWithExpectedSize;
import static com.google.common.io.ByteStreams.toByteArray;
import static java.nio.file.Files.readAllBytes;
import static java.util.Objects.requireNonNull;
import static javax.xml.XMLConstants.XML_NS_URI;
import static javax.xml.stream.XMLStreamConstants.CHARACTERS;
import static javax.xml.stream.XMLStreamConstants.COMMENT;
import static javax.xml.stream.XMLStreamConstants.END_ELEMENT;
import static javax.xml.stream.XMLStreamConstants.PROCESSING_INSTRUCTION;
import static javax.xml.stream.XMLStreamConstants.SPACE;
import static javax.xml.stream.XMLStreamConstants.START_DOCUMENT;
import static javax.xml.stream.XMLStreamConstants.START_ELEMENT;
import static javax.xml.xpath.XPathConstants.NODESET;
import static javax.xml.xpath.XPathConstants.STRING;
import static org.w3c.dom.Node.CDATA_SECTION_NODE;
import static org.w3c.dom.Node.TEXT_NODE;
/**
* XML tool which provides abilities to modify and search
* information in xml document without affecting of
* existing formatting, comments.
* <p>
* XMLTree delegates out of the box implementation of
* org.w3c.dom and provides a lot of functionality
* such as XPath selection.
* How does the XMLTree let content in required state?
* The main idea is simple: know XML elements positions!
* If we know elements positions and source bytes we
* can easily manipulate content as we want.
* So each time when client updates tree,
* tree rewrites source bytes with new information,
* indexes new elements, updates delegated document,
* shifts needed existed elements positions.
* As you may see there are a lot of data manipulations
* when update is going, so <b>you should not use this tool for
* parsing huge xml documents or for often complex updates.</b>
* <p>
* XPath is embedded to XMLTree so each query to tree
* is xpath query. You will be able to select/update
* content provided with XMLTree elements or attributes
* without working with xpath directly.
* <p>
* XMLTree provides methods which do the same
* as model methods but sometimes they are more convenient,
* you can use tree methods as well as model methods.
*
* <p> XMLTree disallows using of {@code DOCTYPE} definition
* in security reasons(XML Entity Expansion injection, XML External Entity Injection).
*
* <pre>
* For example:
*
* XMLTree tree = XMLTree.from(...)
*
* //tree call
* tree.updateText("/project/name", "new name");
*
* //model call
* tree.getSingleElement("/project/name")
* .setText("new name");
*
* </pre>
* <b>NOTE: XMLTree is not thread-safe!</b>
*
* @author Eugene Voevodin
*/
public final class XMLTree {
/**
* Creates XMLTree from input stream.
* Doesn't close the stream
*/
public static XMLTree from(InputStream is) throws IOException {
return new XMLTree(toByteArray(is));
}
/**
* Creates XMLTree from file
*/
public static XMLTree from(java.io.File file) throws IOException {
return from(file.toPath());
}
/**
* Creates XMLTree from path
*/
public static XMLTree from(Path path) throws IOException {
return new XMLTree(readAllBytes(path));
}
/**
* Creates XMLTree from string
*/
public static XMLTree from(String xml) {
return new XMLTree(xml.getBytes(UTF_8));
}
/**
* Creates XMLTree from byte array
*/
public static XMLTree from(byte[] xml) {
requireNonNull(xml, "Required not null bytes");
return new XMLTree(Arrays.copyOf(xml, xml.length));
}
/**
* Creates XMLTree with given root element
*/
public static XMLTree create(String rootName) {
return from(String.format(ROOT_TEMPLATE, rootName, rootName));
}
private static final XMLInputFactory XML_INPUT_FACTORY = XMLInputFactory.newFactory();
private static final DocumentBuilderFactory DOCUMENT_BUILDER_FACTORY = DocumentBuilderFactory.newInstance();
private static final XPathFactory XPATH_FACTORY = XPathFactory.newInstance();
private static final String ROOT_TEMPLATE = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<%s>\n</%s>";
private static final int EXPECTED_NAMESPACES_SIZE = 2;
/** Factories configuration. */
static {
try {
// Disable doctype declaration to avoid: XML Entity Expansion injection, XML External Entity Injection
DOCUMENT_BUILDER_FACTORY.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
// Force parser to use secure settings
DOCUMENT_BUILDER_FACTORY.setFeature(FEATURE_SECURE_PROCESSING, true);
// Disable usage of entity references to avoid: XML External Entity Injection
// It is not needed as long as doctype is disabled, but when doctype is enabled
// this adjustment guarantees avoiding of entity references expansion
DOCUMENT_BUILDER_FACTORY.setExpandEntityReferences(false);
// Force xpath factory to use secure settings
XPATH_FACTORY.setFeature(FEATURE_SECURE_PROCESSING, true);
// Disable DTD support at all to avoid: XML Entity Expansion injection, XML External Entity Injection
XML_INPUT_FACTORY.setProperty(SUPPORT_DTD, false);
// Disable usage of external entities to avoid: XML External Entity Injection
XML_INPUT_FACTORY.setProperty(IS_SUPPORTING_EXTERNAL_ENTITIES, false);
} catch (ParserConfigurationException | XPathFactoryConfigurationException confEx) {
throw XMLTreeException.wrap(confEx);
}
}
private Document document;
private Map<String, String> namespaces;
private List<Element> elements;
private byte[] xml;
private XMLTree(byte[] xml) {
if (xml.length == 0) {
throw new XMLTreeException("Source content is empty");
}
elements = new LinkedList<>();
namespaces = newHashMapWithExpectedSize(EXPECTED_NAMESPACES_SIZE);
this.xml = normalizeLineEndings(xml);
//reason: parser is going to replace all '\r\n' sequences with single '\n'
//which will affect elements position in source xml and produce incorrect XMLTree behaviour
//it comes from spec http://www.w3.org/TR/2004/REC-xml11-20040204/
document = parseQuietly(this.xml);
constructTreeQuietly();
}
/**
* Searches for requested element text.
* If there are more then only element were found
* {@link XMLTreeException} will be thrown
*
* @param expression
* xpath expression to search element
* @return requested element text
* @see Element#getText()
*/
public String getSingleText(String expression) {
return (String)evaluateXPath(expression, STRING);
}
/**
* Searches for requested elements text.
* If there are no elements were found
* empty list will be returned.
* <p>
* You can use this method to request
* not only elements text but for selecting
* attributes values or whatever text information
* which is able to be selected with xpath
*
* @param expression
* xpath expression to search elements
* @return list of elements text or empty list if nothing found
*/
public List<String> getText(String expression) {
return retrieveText(expression);
}
/**
* Searches for requested elements.
*
* @param expression
* xpath expression to search elements
* @return list of found elements or empty list if elements were not found
*/
public List<Element> getElements(String expression) {
final NodeList nodes = (NodeList)evaluateXPath(expression, NODESET);
return asElements(nodes);
}
public <R> List<R> getElements(String expression, ElementMapper<? extends R> mapper) {
final NodeList nodes = (NodeList)evaluateXPath(expression, NODESET);
return asElements(nodes, mapper);
}
/**
* Returns root element for current tree
*/
public Element getRoot() {
return asElement(document.getDocumentElement());
}
/**
* If there are more then only element
* or nothing were found {@link XMLTreeException} will be thrown
*
* @param expression
* xpath expression to search element
* @return found element
*/
public Element getSingleElement(String expression) {
return single(getElements(expression));
}
/**
* Updates requested element text.
* XPath expression should be used only
* for element not for attribute or something else.
* </p>
* If there are more then only element were found
* {@link XMLTreeException} will be thrown
*
* @param expression
* xpath expression to search element
* @param newContent
* new element text content
* @see Element#setText(String)
*/
public void updateText(String expression, String newContent) {
getSingleElement(expression).setText(newContent);
}
/**
* Adds element to the end of the list
* of existed children or adds it as only child.
* <p>
* If there are more then only parent element
* were found {@link XMLTreeException} will be thrown
*
* @param expression
* xpath expression to search parent
* @param newElement
* new element which will be inserted.
* It should be created with same tree instance
*/
public void appendChild(String expression, NewElement newElement) {
single(getElements(expression)).appendChild(newElement);
}
/**
* Inserts element before referenced one.
* All comments related before referenced element
* going to have same positions like they had before.
* <p>
* If there are more then only referenced element
* were found {@link XMLTreeException} will be thrown
*
* @param expression
* xpath expression to search referenced element
* @param newElement
* new element which will be inserted.
* It should be created with same tree instance
*/
public void insertBefore(String expression, NewElement newElement) {
single(getElements(expression)).insertBefore(newElement);
}
/**
* Inserts element after referenced one.
* <p>
* If there are more then only referenced elements
* were found {@link XMLTreeException} will be thrown
*
* @param expression
* xpath expression to search referenced element
* @param newElement
* new element which will be inserted.
* It should be created with same tree instance
*/
public void insertAfter(String expression, NewElement newElement) {
single(getElements(expression)).insertAfter(newElement);
}
/**
* Removes requested element.
* If there are was any <b>text</b> before removal
* element it will be removed as well.
* It is important when we need to keep formatting
* pretty - if it was pretty. It is really strange
* when parent element contains not only whitespaces
* but another text content.
* <p>
* If there are more then only referenced element
* were found {@link XMLTreeException} will be thrown
*
* @param expression
* xpath expression to remove element
*/
public void removeElement(String expression) {
single(getElements(expression)).remove();
}
/**
* Returns copy of source bytes.
* TODO: write replacement explanation
*/
public byte[] getBytes() {
final String separator = System.getProperty("line.separator");
if (!"\n".equals(separator)) {
return replaceAll(xml, "\n".getBytes(), separator.getBytes());
}
return Arrays.copyOf(xml, xml.length);
}
/**
* Writes copy of source bytes to output stream.
* Doesn't close the stream
*/
public void writeTo(OutputStream outputStream) throws IOException {
outputStream.write(getBytes());
}
/**
* Writes source bytes to path
*/
public void writeTo(Path path) throws IOException {
Files.write(path, getBytes());
}
/**
* Writes source bytes to file
*/
public void writeTo(java.io.File file) throws IOException {
Files.write(file.toPath(), getBytes());
}
/**
* Evaluates xpath expression with given return type.
* Rethrows all exceptions as {@link XMLTreeException}
*/
@SuppressWarnings("unchecked")
private Object evaluateXPath(String expression, QName returnType) {
final XPath xpath = XPATH_FACTORY.newXPath();
try {
return xpath.evaluate(expression, document, returnType);
} catch (XPathExpressionException xpathEx) {
throw XMLTreeException.wrap(xpathEx);
}
}
/**
* Parses document using {@link DocumentBuilder}
* Rethrows all exceptions as {@link XMLTreeException}
*/
private Document parseQuietly(byte[] xml) {
try {
final DocumentBuilder db = DOCUMENT_BUILDER_FACTORY.newDocumentBuilder();
return db.parse(new ByteArrayInputStream(xml));
} catch (Exception ex) {
throw XMLTreeException.wrap(ex);
}
}
/**
* Evaluates xpath expression and maps result as list of strings
* using {@link Node#getTextContent()} method
*/
private List<String> retrieveText(String expression) {
final NodeList nodeList = (NodeList)evaluateXPath(expression, NODESET);
final List<String> elementsText = new ArrayList<>(nodeList.getLength());
for (int i = 0; i < nodeList.getLength(); i++) {
elementsText.add(nodeList.item(i).getTextContent());
}
return elementsText;
}
/**
* Constructs tree based on segments which are supplied by {@link XMLStreamReader}.
* Before this method is invoked {@link #document} should be initialized first.
* For START_ELEMENT, END_ELEMENT, CHARACTERS reader provides offset from
* start of source array bytes, so we can fetch position of elements and text.
* Each created element associated with related {@link Node} and vise-versa.
*/
private void constructTree() throws XMLStreamException {
final XMLStreamReader reader = newXMLStreamReader();
final LinkedList<Element> stack = new LinkedList<>();
//before element open tag index
int beforeStart = rootStart(xml) - 1;
//used to associate each element with document node
Node node = document.getDocumentElement();
//used to hold previous reader event
int prevEvent = START_DOCUMENT;
while (reader.hasNext()) {
switch (reader.next()) {
case START_ELEMENT:
final Element newElement = new Element(this);
newElement.start = new Segment(beforeStart + 1, elementRight(beforeStart + 1, reader));
//if new node is not xml root - set up relationships
if (!stack.isEmpty()) {
node = deepNext(node, true);
}
//connect node with element
node.setUserData("element", newElement, null);
newElement.delegate = safeCast(node);
//let next event know about its start
beforeStart = newElement.start.right;
//if element has declared namespaces register it
putNamespaces(reader);
stack.push(newElement);
break;
case END_ELEMENT:
final Element element = stack.pop();
element.end = new Segment(beforeStart + 1, elementRight(beforeStart + 1, reader));
elements.add(element);
beforeStart = element.end.right;
break;
case CHARACTERS:
//characters event may be invoked 2 or more times
//on the element text, but related node is single text node
//so the only segment should be created for it
if (prevEvent == CHARACTERS) continue;
final Element current = stack.peek();
if (current.text == null) {
//TODO replace with array list as we know current node 'text nodes' count
current.text = new LinkedList<>();
}
final Node nextNode = deepNext(node, true);
final int left = beforeStart + 1;
final int right = left + textLength(nextNode) - 1;
current.text.add(new Segment(left, right));
beforeStart = right;
node = skipTextNodes(nextNode);
break;
case COMMENT:
case SPACE:
case PROCESSING_INSTRUCTION:
if (!stack.isEmpty()) {
node = deepNext(node, true);
beforeStart = lastIndexOf(xml, '>', reader.getLocation().getCharacterOffset());
}
break;
default:
//DO NOTHING
}
prevEvent = reader.getEventType();
}
}
/**
* Returns length of cdata and text nodes chain.
*/
private int textLength(Node node) {
int length = node.getTextContent().length();
node = node.getNextSibling();
while (node != null && (node.getNodeType() == TEXT_NODE || node.getNodeType() == CDATA_SECTION_NODE)) {
length += node.getTextContent().length();
if (node.getNodeType() == CDATA_SECTION_NODE) {
length += 12; //<![CDATA[]]> - 12
}
node = node.getNextSibling();
}
return length;
}
/**
* Returns safe Element instance for node or throws exception
* if it is not possible to cast {@code node} to {@link org.w3c.dom.Element}.
*/
private org.w3c.dom.Element safeCast(Node node) {
if (!(node instanceof org.w3c.dom.Element)) {
throw new XMLTreeException("It is not possible to associate xml elements");
}
return (org.w3c.dom.Element)node;
}
/**
* Returns last text or cdata node in the chain of cdata and text nodes.
* <p>
* i.e. node1 is text <i>node</i> and <i>node1</i> has next sibling <i>node2</i> as cdata node and
* <i>node2</i> has next sibling <i>node3</i> as text node and node3 has next sibling <i>node4</i> as element
* node, then <i>node3</i> will be returned as last text node in the chain. Consider following examples:
* <pre>
* 1.
* node3 - last text node
*
* text cdata text element
* node1 -> node2 -> node3 -> node4
*
* 2.
* node2 - last text result
*
* text cdata
* node1 -> node2 -> null
*
* </pre>
*/
private Node skipTextNodes(Node node) {
final Node next = node.getNextSibling();
if (next != null && (next.getNodeType() == CDATA_SECTION_NODE || next.getNodeType() == TEXT_NODE)) {
return skipTextNodes(next);
}
return node;
}
/**
* Searches for the element start right bound index.
* TODO respect element attributes text content while checking '<'
*/
private int elementRight(int left, XMLStreamReader reader) {
int rightIdx = lastIndexOf(xml, '>', reader.getLocation().getCharacterOffset());
int leftIdx = lastIndexOf(xml, '<', rightIdx);
while (leftIdx > left) {
rightIdx = lastIndexOf(xml, '>', rightIdx - 1);
leftIdx = lastIndexOf(xml, '<', rightIdx);
}
return rightIdx;
}
private Node deepNext(Node node, boolean deep) {
if (deep && node.getChildNodes().getLength() != 0) {
return node.getFirstChild();
}
final Node next = node.getNextSibling();
if (next != null) {
return next;
} else if (node == document.getDocumentElement()) {
return node;
}
return deepNext(node.getParentNode(), false);
}
/**
* Same as {@link #constructTree()}, only difference
* that it wraps {@link XMLStreamException} to {@link XMLTreeException}
*/
private void constructTreeQuietly() {
try {
constructTree();
} catch (XMLStreamException xmlEx) {
throw XMLTreeException.wrap(xmlEx);
}
}
/**
* Should be invoked on ELEMENT_START event
*/
private void putNamespaces(XMLStreamReader reader) {
for (int i = 0; i < reader.getNamespaceCount(); i++) {
final String prefix = reader.getNamespacePrefix(i);
if (prefix != null) {
putNamespace(prefix, reader.getNamespaceURI(i));
}
}
}
/**
* Creates new stream reader instance
*/
private XMLStreamReader newXMLStreamReader() {
try {
return XML_INPUT_FACTORY.createXMLStreamReader(new ByteArrayInputStream(xml), "UTF-8");
} catch (Exception xmlEx) {
throw XMLTreeException.wrap(xmlEx);
}
}
/**
* Updates element text content. Update based on element text segments.
* If element doesn't have any text segment then new segment will
* be created which positions based on container bounds, otherwise
* only first text segment will be used for update, other text segments
* will be removed.
*/
void updateText(Element target) {
//it may be null when target element doesn't contain
//text <element></element> so CHARACTERS event was not processed
if (target.text == null) {
target.text = new LinkedList<>();
//updateSegmentContent will set up right bound
target.text.add(new Segment(target.start.right + 1, target.start.right));
}
final Iterator<Segment> segIt = target.text.iterator();
final Segment first = segIt.next();
//removing all segments instead of first
while (segIt.hasNext()) {
final Segment removal = segIt.next();
segIt.remove();
removeSegment(removal);
}
updateSegmentContent(first, target.getText());
}
void updateAttributeValue(Attribute attribute, String oldValue) {
final Segment segment = valueSegment(attribute, oldValue);
updateSegmentContent(segment, attribute.getValue());
}
/**
* Adds new element to the end of children list with given parent.
*/
void appendChild(NewElement newElement, Element relatedToNew, Element parent) {
final int level = level(parent) + 1;
final int lengthBefore = xml.length;
final int insertHere = lastIndexOf(xml, '>', parent.end.left) + 1;
//inserting new element bytes to tree bytes
xml = insertInto(xml,
insertHere,
'\n' + tabulate(newElement.asString(), level));
//shift existing segments which are after parent start
shiftSegments(insertHere, xml.length - lengthBefore);
//create and set up start, end, text segments to created element
applySegments(newElement, relatedToNew, insertHere - 1, level);
//let tree know about added element
registerElement(relatedToNew);
}
/**
* Inserts element after referenced one
*/
void insertAfter(NewElement newElement, Element relatedToNew, Element refElement) {
final int level = level(refElement);
final int lengthBefore = xml.length;
//inserting new element bytes to tree bytes
xml = insertInto(xml,
refElement.end.right + 1,
'\n' + tabulate(newElement.asString(), level));
//shift existing segments which are after parent start
shiftSegments(refElement.end.right, xml.length - lengthBefore);
//create and set up start, end, text segments to created element
//+1 because of \n
applySegments(newElement, relatedToNew, refElement.end.right, level);
//let tree know about inserted element
registerElement(relatedToNew);
}
/**
* Inserts element before referenced one.
* It is important to let all related to
* {@code refElement} comments on their places,
* so to avoid deformation we inserting new element after
* previous sibling or after element parent
* if element doesn't have previous sibling
*/
void insertAfterParent(NewElement newElement, Element relatedToNew, Element parent) {
final int level = level(parent) + 1;
final int lengthBefore = xml.length;
//inserting after parent
xml = insertInto(xml,
parent.start.right + 1,
'\n' + tabulate(newElement.asString(), level));
//shift existing segments which are after parent start
shiftSegments(parent.start.right, xml.length - lengthBefore);
//create and set up start, end, text segments to created element
applySegments(newElement, relatedToNew, parent.start.right, level);
//let tree know about inserted element
registerElement(relatedToNew);
}
/**
* Removes element bytes from tree.
* <p>
* It is important to save xml tree pretty view,
* so element should be removed without of destroying
* style of xml document.
* <pre>
* e.g.
*
* {@literal <level1>}
* {@literal <level2>} {@literal <level2>+\n}
* {@literal <level2>} {@literal <level2+>\n}
* {@literal <level1>}
*
* first + is before left border
* last + is before right border
*
* segment [first, last] - will be removed
* </pre>
* So after removing - formatting will be the same.
* We can't remove just element from start to end
* because it will produce not pretty formatting for
* good and pretty formatted before document.
*/
void removeElement(Element element) {
final int leftBound = lastIndexOf(xml, '>', element.start.left) + 1;
final int lengthBefore = xml.length;
//if text segment before removal element
//exists it should go to hell with removal
if (leftBound != element.start.left - 1) {
removeSegmentFromElement(element.getParent(), leftBound);
}
//replacing content with nothing
xml = insertBetween(xml,
leftBound,
element.end.right,
"");
//shift all elements which are right from removed element
shiftSegments(element.end.right, xml.length - lengthBefore);
//let tree know that element is not a family member
unregisterElement(element);
}
/**
* Inserts new attribute value content to tree bytes
*/
void insertAttribute(NewAttribute attribute, Element owner) {
final int len = xml.length;
//inserting new attribute content
xml = insertInto(xml,
owner.start.right,
' ' + attribute.asString());
//shift all elements which are right from removed element
shiftSegments(owner.start.left - 1, xml.length - len);
}
/**
* Removes element bytes from tree
*/
void removeAttribute(Attribute attribute) {
final Element element = attribute.getElement();
final int lengthBefore = xml.length;
final Segment segment = attributeSegment(attribute);
//replacing attribute segment with nothing
xml = insertBetween(xml,
segment.left - 1,
segment.right,
"");
//shift all elements which are left from owner left
shiftSegments(element.start.left, xml.length - lengthBefore);
}
//TODO should it be public?
void putNamespace(String prefix, String uri) {
namespaces.put(prefix, uri);
}
//TODO should it be public?
String getNamespaceUri(String prefix) {
final String uri = namespaces.get(prefix);
return uri == null ? XML_NS_URI : uri;
}
/**
* Shift given segment on offset if it is righter then idx
*
* @param segment
* segment to shift
* @param leftBound
* left bound
* @param offset
* offset to shift on, it can be negative
*/
private void shiftSegment(Segment segment, int leftBound, int offset) {
if (segment.left > leftBound) {
segment.left += offset;
segment.right += offset;
}
}
/**
* Removes segment which left bound equal to {@param left} from element
*/
private void removeSegmentFromElement(Element element, int left) {
for (Iterator<Segment> segIt = element.text.iterator(); segIt.hasNext(); ) {
if (segIt.next().left == left) {
segIt.remove();
break;
}
}
}
/**
* Iterates all existed elements and shifts their segments if needed
*/
private void shiftSegments(int fromIdx, int offset) {
for (Element element : elements) {
if (element.end.left > fromIdx) {
shiftSegment(element.start, fromIdx, offset);
shiftSegment(element.end, fromIdx, offset);
if (element.text != null) {
for (Segment textSegment : element.text) {
shiftSegment(textSegment, fromIdx, offset);
}
}
}
}
}
/**
* Removes given segment from source bytes and shifts segments left
* on offset equal to removal segment length
*/
private void removeSegment(Segment segment) {
final int lengthBefore = xml.length;
xml = insertBetween(xml, segment.left, segment.right, "");
shiftSegments(segment.left, xml.length - lengthBefore);
}
/**
* Inserts content bytes between left and right segment bounds
* and shifts segments on offset equal to difference between new and old
* source bytes length
*/
private void updateSegmentContent(Segment segment, String content) {
final int lengthBefore = xml.length;
xml = insertBetween(xml, segment.left, segment.right, content);
shiftSegments(segment.left, xml.length - lengthBefore);
segment.right = segment.left + content.length() - 1;
}
/**
* Adds element and it children to tree
*/
private void registerElement(Element element) {
elements.add(element);
for (Element child : element.getChildren()) {
registerElement(child);
}
}
/**
* Removes element and children from tree
*/
private void unregisterElement(Element element) {
elements.remove(element);
for (Element child : element.getChildren()) {
unregisterElement(child);
}
}
/**
* Retrieves attribute segment
*/
private Segment attributeSegment(Attribute attribute) {
final Element owner = attribute.getElement();
final byte[] name = attribute.getName().getBytes();
final byte[] value = attribute.getValue().getBytes();
final int attrLeft = indexOfAttributeName(xml, name, owner.start.left + owner.getName().length());
final int valueLeft = indexOf(xml, value, attrLeft + name.length);
return new Segment(attrLeft, valueLeft + value.length);
}
/**
* Retrieves attribute value segment
*/
private Segment valueSegment(Attribute attribute, String oldValue) {
final Element owner = attribute.getElement();
final byte[] name = attribute.getName().getBytes();
final byte[] value = oldValue.getBytes();
final int attrLeft = indexOfAttributeName(xml, name, owner.start.left + owner.getName().length());
final int valueLeft = indexOf(xml, value, attrLeft + name.length);
return new Segment(valueLeft, valueLeft + value.length - 1);
}
/**
* Creates segments for newly created element and related children
*/
private int applySegments(NewElement newElement,
Element relatedToNew,
int prevElementCloseRight,
int level) {
// text length before element
// child - new element
// '+' - text before element
//
// <parent>\n
// ++++<child>...
final int levelTextLength = level * SPACES_IN_TAB;
// '*' - before element open tag pos
//
// | prevElementCloseRight
// v
//<parent>\n *<child>...
// +1 because of '\n'
final int beforeOpenLeft = 1 + prevElementCloseRight + levelTextLength;
//we should add text segment which
//is before new element to the parent text segments and start to track it
final Element parent = relatedToNew.getParent();
if (parent.text == null) {
parent.text = new LinkedList<>();
}
parent.text.add(new Segment(prevElementCloseRight + 1, beforeOpenLeft));
//pos of open tag right '>'
final int openRight = beforeOpenLeft + openTagLength(newElement);
relatedToNew.start = new Segment(beforeOpenLeft + 1, openRight);
//if element is void it doesn't have children and text
//and it has same start and end so we can initialize
//only start and end segments
if (relatedToNew.isVoid()) {
relatedToNew.end = relatedToNew.start;
return openRight;
}
//if element has children it doesn't have text instead of
//whitespaces, so all what we need - detect element close tag segment
//to do so we need to map segments for all children first
int childRight = openRight;
if (newElement.hasChildren()) {
final Iterator<NewElement> newChIt = newElement.getChildren().iterator();
final Iterator<Element> chIt = relatedToNew.getChildren().iterator();
while (newChIt.hasNext()) {
childRight = applySegments(newChIt.next(),
chIt.next(),
childRight,
level + 1);
}
} else {
relatedToNew.text = new LinkedList<>();
}
//before element close tag pos
// +
//<parent>\n <child>text</child>
int beforeCloseLeft;
if (newElement.hasChildren()) {
beforeCloseLeft = childRight + levelTextLength + 1;
} else {
beforeCloseLeft = childRight + newElement.getText().length();
}
relatedToNew.text.add(new Segment(childRight + 1, beforeCloseLeft));
relatedToNew.end = new Segment(beforeCloseLeft + 1, beforeCloseLeft + closeTagLength(newElement));
return relatedToNew.end.right;
}
private byte[] normalizeLineEndings(byte[] src) {
final String separator = System.getProperty("line.separator");
//replacing all \r\n with \n
if (separator.equals("\r\n")) {
src = replaceAll(src, "\r\n".getBytes(), "\n".getBytes());
}
//replacing all \r with \n to prevent combination of \r\n which was created after
//\r\n replacement, i.e. content \r\r\n after first replacement will be \r\n which is not okay
return replaceAll(src, "\r".getBytes(), "\n".getBytes());
}
/**
* Describes element, attribute or text position in
* the source array of bytes.
*/
static class Segment {
int left;
int right;
Segment(int left, int right) {
this.left = left;
this.right = right;
}
@Override
public boolean equals(Object obj) {
if (obj == this) {
return true;
}
if (!(obj instanceof Segment)) {
return false;
}
final Segment other = (Segment)obj;
return other.left == left && other.right == right;
}
@Override
public int hashCode() {
return 31 * left ^ 31 * right;
}
@Override
public String toString() {
return "left: " + left + ", right: " + right;
}
}
@Override
public String toString() {
return new String(getBytes(), UTF_8);
}
}