package com.cloudhopper.commons.xml;
/*
* #%L
* ch-commons-xbean
* %%
* Copyright (C) 2012 Cloudhopper by Twitter
* %%
* 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.
* #L%
*/
// third party imports
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Represents an XPath. Parses a subset of valid XPath queries and exposes
* them in getter and setter properties.
*
* @author joelauer
*/
public class XPath {
private static Logger logger = LoggerFactory.getLogger(XPath.class);
private String fullXPath;
private String normalizedXPath;
private boolean includeChildren;
private XPath() {
fullXPath = null;
this.normalizedXPath = null;
includeChildren = false;
}
/**
* Returns the normalized XPath. For example, "/nodeA/*" will return "/nodeA"
*/
public String getNormalizedXPath() {
return this.normalizedXPath;
}
/**
* Returns the full XPath (not normalized). For example, "/nodeA/*" will return "/nodeA/*"
*/
public String getFullXPath() {
return this.fullXPath;
}
/**
* Returns whether this XPath include child nodes.
* @return
*/
public boolean includeChildren() {
return this.includeChildren;
}
/**
* Does the path match this XPath?
* @param xpath A path such as "/nodeA/nodeB"
* @return True if the xpath matches, false otherwise.
*/
public boolean matches(String xpath) {
return matches(xpath, true);
}
/**
* Does the path match? If includeParent is true, then a parent match
* is included. For example, an XPath of "/nodeA/nodeB" will include a match
* for "/nodeA" if includeParent is true. Otherwise, "/nodeA" will return
* false.
*
* @param xpath A path such as "/nodeA/nodeB"
* @return True if the xpath matches, false otherwise.
*/
public boolean matches(String xpath, boolean includeParent) {
if (xpath == null || xpath.equals("")) {
throw new IllegalArgumentException("xpath cannot be null or empty");
}
// make sure xpath doesn't end with an /
if (xpath.endsWith("/")) {
throw new IllegalArgumentException("Should not end with /");
}
// the xpath might be a parent path
if (includeParent && normalizedXPath.startsWith(xpath+"/")) {
return true;
}
// otherwise, either this is an exact match or a child of a previous match
if (xpath.startsWith(normalizedXPath)) {
// if lengths are equal, then its a perfect match
if (xpath.length() == normalizedXPath.length()) {
return true;
} else if (includeChildren) {
return true;
} else {
return false;
}
}
return false;
}
/**
* Helper method for compiling xpath and selecting a node in one method call.
* If the same xpath will be reused, it's more efficient to create an instance
* than to use this method.
* <br><br>
* Selects the first node starting from the rootNode that matches this xpath.
* Useful for selecting a subtree. For example, if the xpath is "/nodeA/nodeB",
* and that path exists starting from rootNode, then "nodeB" would be returned.
* @param rootNode The node to start from
* @param xpath The xpath to select
* @return The selected node that matches the xpath
*/
public static XmlParser.Node select(XmlParser.Node rootNode, String xpath) {
XPath xp = XPath.parse(xpath);
return xp.select(rootNode);
}
/**
* Selects the first node starting from the rootNode that matches this xpath.
* Useful for selecting a subtree. For example, if the xpath is "/nodeA/nodeB",
* and that path exists starting from rootNode, then "nodeB" would be returned.
* @param rootNode The node to start from
* @return The selected node that matches the xpath
*/
public XmlParser.Node select(XmlParser.Node rootNode) {
// check that the first char is /
if (normalizedXPath.charAt(0) != '/') {
throw new IllegalArgumentException("First part of xpath must be /");
}
// check that the path length is > 2
if (normalizedXPath.length() <= 1) {
throw new IllegalArgumentException("Xpath length must be > 1");
}
// we'll always start at the root
XmlParser.Node node = rootNode;
// navigate thru each part of the "path"
int startPos = 1;
boolean selectedRootNode = false;
do {
// find the next occurrence of the / char
int nextPos = normalizedXPath.indexOf('/', startPos+1);
// if not found then this is the last tag
if (nextPos < 0) {
// set the nextPos to the end of this string
nextPos = normalizedXPath.length();
}
String tag = normalizedXPath.substring(startPos, nextPos);
logger.debug("tag: " + tag);
// unique case: we may currently be at the root node, in which
// case we want to skip it....
if (!selectedRootNode) {
// the "tag" MUST equal the root node's tag
if (!node.getTag().equals(tag)) {
// xpath didn't select the root node correctly
return null;
} else {
// xpath selected root node correctly, we basically will
// skip incrementing the node
selectedRootNode = true;
}
} else {
// try to find this tag
node = node.getChild(tag);
if (node == null) {
// return null since this path doesn't exist
return null;
}
}
// increment startPos
startPos = nextPos+1;
} while (startPos < normalizedXPath.length());
// if we get here, then return the node
return node;
}
/**
* Parses an XPath and returns its representation as an object. Only a subset
* of XPath queries are supported. Valid queries are:<br><br>
* /<br>
* /*<br>
* /nodeA<br>
* /nodeA/*<br>
*
* @param xpath
* @return
* @throws java.lang.IllegalArgumentException
*/
public static XPath parse(String xpath) throws IllegalArgumentException {
XPath xp = new XPath();
// save the xpath as the full xpath
xp.fullXPath = xpath;
// does the xpath end with /*
if (xpath.length() >= 2 && xpath.endsWith("/*")) {
xp.includeChildren = true;
xpath = xpath.substring(0, xpath.length()-2);
}
// normalized path is the rest
xp.normalizedXPath = xpath;
return xp;
}
}