diff --git a/src/main/java/eu/europa/ted/eforms/xpath/XPathAnchor.java b/src/main/java/eu/europa/ted/eforms/xpath/XPathAnchor.java
new file mode 100644
index 0000000..fae73a9
--- /dev/null
+++ b/src/main/java/eu/europa/ted/eforms/xpath/XPathAnchor.java
@@ -0,0 +1,35 @@
+package eu.europa.ted.eforms.xpath;
+
+/**
+ * Where a path starts from. A path is written either relative to the current context, or anchored
+ * at the root of the document, or as a search from the root at any depth.
+ *
+ *
The steps of a parsed path do not carry this, as the separator that expresses it is not a
+ * step. It is recorded separately so that a path can be composed back from its steps.
+ */
+public enum XPathAnchor {
+
+ /** The path starts from the current context: {@code a/b}. */
+ RELATIVE(""),
+
+ /** The path starts at the root of the document: {@code /a/b}. */
+ ROOT("/"),
+
+ /** The path searches from the root, matching at any depth: {@code //a/b}. */
+ DESCENDANT_FROM_ROOT("//");
+
+ private final String separator;
+
+ XPathAnchor(final String separator) {
+ this.separator = separator;
+ }
+
+ /** The separator that a path with this anchor begins with. */
+ public String getSeparator() {
+ return separator;
+ }
+
+ public boolean isAbsolute() {
+ return this != RELATIVE;
+ }
+}
diff --git a/src/main/java/eu/europa/ted/eforms/xpath/XPathInfo.java b/src/main/java/eu/europa/ted/eforms/xpath/XPathInfo.java
index 9c9550e..9b11fe5 100644
--- a/src/main/java/eu/europa/ted/eforms/xpath/XPathInfo.java
+++ b/src/main/java/eu/europa/ted/eforms/xpath/XPathInfo.java
@@ -7,6 +7,7 @@ public class XPathInfo {
private LinkedList steps = new LinkedList<>();
private String pathToLastElement;
private String attributeName;
+ private XPathAnchor anchor = XPathAnchor.RELATIVE;
public List getSteps() {
return steps;
@@ -28,6 +29,19 @@ public void setPathToLastElement(String pathToLastElement) {
this.pathToLastElement = pathToLastElement;
}
+ /** Where the path starts from. See {@link XPathAnchor}. */
+ public XPathAnchor getAnchor() {
+ return anchor;
+ }
+
+ void setAnchor(XPathAnchor anchor) {
+ this.anchor = anchor;
+ }
+
+ public boolean isAbsolute() {
+ return anchor.isAbsolute();
+ }
+
public boolean isAttribute() {
return attributeName != null;
}
diff --git a/src/main/java/eu/europa/ted/eforms/xpath/XPathListenerImpl.java b/src/main/java/eu/europa/ted/eforms/xpath/XPathListenerImpl.java
index 93db210..0cf84a8 100644
--- a/src/main/java/eu/europa/ted/eforms/xpath/XPathListenerImpl.java
+++ b/src/main/java/eu/europa/ted/eforms/xpath/XPathListenerImpl.java
@@ -14,6 +14,7 @@
import org.antlr.v4.runtime.tree.ParseTreeWalker;
import eu.europa.ted.eforms.xpath.XPath20Parser.AbbrevforwardstepContext;
+import eu.europa.ted.eforms.xpath.XPath20Parser.PathexprContext;
import eu.europa.ted.eforms.xpath.XPath20Parser.AxisstepContext;
import eu.europa.ted.eforms.xpath.XPath20Parser.FilterexprContext;
import eu.europa.ted.eforms.xpath.XPath20Parser.PredicateContext;
@@ -25,11 +26,13 @@ class XPathListenerImpl extends XPath20BaseListener {
private CharStream inputStream;
private LinkedList steps;
private int inPredicate = 0;
+ private boolean anchorFound;
public XPathInfo parse(String xpathInput) {
steps = new LinkedList<>();
xpathInfo = new XPathInfo();
inPredicate = 0;
+ anchorFound = false;
this.inputText = xpathInput;
this.inputStream = CharStreams.fromString(xpathInput);
@@ -96,6 +99,29 @@ public void exitFilterexpr(FilterexprContext ctx) {
}
}
+ /**
+ * The grammar spells the anchor out: {@code pathexpr : (SLASH relativepathexpr?) | (SS
+ * relativepathexpr) | relativepathexpr}. Reading it from the parse tree rather than from the
+ * start of the input keeps it right for paths the lexer has to look at first, such as one
+ * preceded by a comment. Only the outermost path expression is the path's own anchor; those
+ * inside a predicate belong to the predicate.
+ */
+ @Override
+ public void enterPathexpr(PathexprContext ctx) {
+ if (inPredicate > 0 || anchorFound) {
+ return;
+ }
+ anchorFound = true;
+
+ if (ctx.SS() != null) {
+ xpathInfo.setAnchor(XPathAnchor.DESCENDANT_FROM_ROOT);
+ } else if (ctx.SLASH() != null) {
+ xpathInfo.setAnchor(XPathAnchor.ROOT);
+ } else {
+ xpathInfo.setAnchor(XPathAnchor.RELATIVE);
+ }
+ }
+
@Override
public void enterPredicate(PredicateContext ctx) {
this.inPredicate++;
diff --git a/src/main/java/eu/europa/ted/eforms/xpath/XPathProcessor.java b/src/main/java/eu/europa/ted/eforms/xpath/XPathProcessor.java
index fa6ef4b..63d57f0 100644
--- a/src/main/java/eu/europa/ted/eforms/xpath/XPathProcessor.java
+++ b/src/main/java/eu/europa/ted/eforms/xpath/XPathProcessor.java
@@ -1,5 +1,6 @@
package eu.europa.ted.eforms.xpath;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
@@ -34,10 +35,20 @@ public static String join(final String first, final String second) {
return first;
}
- LinkedList firstPartSteps = new LinkedList<>(parse(first).getSteps());
+ final XPathInfo firstPart = parse(first);
+ LinkedList firstPartSteps = new LinkedList<>(firstPart.getSteps());
LinkedList secondPartSteps = new LinkedList<>(parse(second).getSteps());
- return getJoinedXPath(firstPartSteps, secondPartSteps);
+ final XPathAnchor anchor = firstPart.getAnchor();
+ final String joined = getJoinedXPath(firstPartSteps, secondPartSteps, anchor);
+
+ if (joined.isEmpty()) {
+ // The back-steps consumed both parts, so the join resolves to where it started from: the
+ // root of the document if the first part was anchored there, and the current context if not.
+ return anchor.isAbsolute() ? "/" : ".";
+ }
+
+ return anchor.getSeparator() + joined;
}
public static String contextualize(final String contextXpath, final String xpath) {
@@ -97,7 +108,7 @@ private static String getContextualizedXpath(Queue contextQueue,
// remaining in the pathQueue.
while (!pathQueue.isEmpty()) {
final XPathStep step = pathQueue.poll();
- relativeXpath += "/" + step.getStepText() + step.getPredicateText();
+ relativeXpath += "/" + step;
}
// We remove any leading forward slashes from the resulting xPath.
@@ -129,15 +140,26 @@ private static String getContextualizedXpath(Queue contextQueue,
}
private static String getJoinedXPath(LinkedList first,
- final LinkedList second) {
+ final LinkedList second, final XPathAnchor anchor) {
List dotSteps = Arrays.asList("..", ".");
- while (second.getFirst().getStepText().equals("..")
+
+ // A path that searches from the root matches at any depth, so the position of its first step is
+ // not known. Cancelling that step against a parent step would claim a position it does not
+ // have, so it is left in place.
+ final int minimumStepsToKeep = anchor == XPathAnchor.DESCENDANT_FROM_ROOT ? 1 : 0;
+ while (!second.isEmpty() && first.size() > minimumStepsToKeep
+ && second.getFirst().getStepText().equals("..")
&& !dotSteps.contains(first.getLast().getStepText()) && !first.getLast().isVariableStep()) {
second.removeFirst();
first.removeLast();
}
- return first.stream().map(f -> f.getStepText()).collect(Collectors.joining("/"))
- + "/" + second.stream().map(s -> s.getStepText()).collect(Collectors.joining("/"));
+ // Both parts are joined as one sequence of steps. Gluing the two halves together with a
+ // separator of our own would put one in front of the result whenever the first part is empty,
+ // which happens when the back-steps above consume all of it.
+ final List steps = new ArrayList<>(first);
+ steps.addAll(second);
+
+ return steps.stream().map(s -> s.toString()).collect(Collectors.joining("/"));
}
}
diff --git a/src/main/java/eu/europa/ted/eforms/xpath/XPathStep.java b/src/main/java/eu/europa/ted/eforms/xpath/XPathStep.java
index 7806dbe..862c43e 100644
--- a/src/main/java/eu/europa/ted/eforms/xpath/XPathStep.java
+++ b/src/main/java/eu/europa/ted/eforms/xpath/XPathStep.java
@@ -29,6 +29,16 @@ public String getPredicateText() {
return String.join("", predicates);
}
+ /**
+ * The step as it was written in the path it was parsed from, predicates included. Use this
+ * wherever a step is put back into a path: the step text and its predicates are held separately,
+ * so composing a path from the step text alone silently discards the predicates.
+ */
+ @Override
+ public String toString() {
+ return getStepText() + getPredicateText();
+ }
+
@Override
public int hashCode() {
return Objects.hash(stepText, predicates);
diff --git a/src/test/java/eu/europa/ted/eforms/xpath/XPathProcessorTest.java b/src/test/java/eu/europa/ted/eforms/xpath/XPathProcessorTest.java
index dd62236..086f9d1 100644
--- a/src/test/java/eu/europa/ted/eforms/xpath/XPathProcessorTest.java
+++ b/src/test/java/eu/europa/ted/eforms/xpath/XPathProcessorTest.java
@@ -211,4 +211,64 @@ void testJoin() {
assertEquals("a/b/c/d", XPathProcessor.join("a/b", "c/d"));
assertEquals("a/x/y", XPathProcessor.join("a/b/c", "../../x/y"));
}
+
+ @Test
+ void testJoinPreservesPredicates() {
+ assertEquals("a/b/c[x = 'y']/d", XPathProcessor.join("a/b", "c[x = 'y']/d"));
+ assertEquals("a[x = 'y']/b/c", XPathProcessor.join("a[x = 'y']/b", "c"));
+ assertEquals("a[x = 'y']/b[p]/c[q]", XPathProcessor.join("a[x = 'y']", "b[p]/c[q]"));
+ }
+
+ @Test
+ void testJoinPreservesTheLeadingSeparator() {
+ assertEquals("a/b/c", XPathProcessor.join("a/b", "c"));
+ assertEquals("/a/b/c", XPathProcessor.join("/a/b", "c"));
+ assertEquals("/a/b[x = 'y']/c", XPathProcessor.join("/a/b[x = 'y']", "c"));
+
+ // "/a" and "//a" do not select the same thing, so the separator is kept as it was written.
+ assertEquals("//a/b", XPathProcessor.join("//a", "b"));
+
+ // When the back-steps consume the whole of the first part, the result is still anchored at the
+ // root, and must not become a descendant search.
+ assertEquals("/b", XPathProcessor.join("/", "b"));
+ assertEquals("/b", XPathProcessor.join("/a", "../b"));
+ assertEquals("b", XPathProcessor.join("a", "../b"));
+ }
+
+ /**
+ * A path beginning with "//" matches at any depth, so its first step cannot be cancelled against
+ * a parent step: "//a/.." selects the parents of every a element, which is not the root.
+ */
+ @Test
+ void testJoinKeepsTheFirstStepOfADescendantSearch() {
+ assertEquals("//a/..", XPathProcessor.join("//a", ".."));
+ assertEquals("//a/../b", XPathProcessor.join("//a", "../b"));
+ assertEquals("//a/b", XPathProcessor.join("//a", "b"));
+ }
+
+ /**
+ * The anchor is read from the parse tree, not from the start of the input, so anything the lexer
+ * skips before the path does not hide it. A comment is valid XPath and is skipped.
+ */
+ @Test
+ void testJoinSeesTheAnchorPastAComment() {
+ assertEquals("/a/b", XPathProcessor.join("(: a comment :) /a", "b"));
+ assertEquals("//a/b", XPathProcessor.join("(: a comment :) //a", "b"));
+ assertEquals("a/b", XPathProcessor.join("(: a comment :) a", "b"));
+ }
+
+ /** A path inside a predicate has its own anchor, which is not the anchor of the path. */
+ @Test
+ void testJoinTakesTheAnchorOfTheOuterPath() {
+ assertEquals("a[/b]/c", XPathProcessor.join("a[/b]", "c"));
+ assertEquals("/a[b]/c", XPathProcessor.join("/a[b]", "c"));
+ }
+
+ @Test
+ void testJoinResolvingToWhereItStarted() {
+ assertEquals("/", XPathProcessor.join("/a", ".."));
+ assertEquals("/", XPathProcessor.join("/a/b", "../.."));
+ assertEquals(".", XPathProcessor.join("a", ".."));
+ assertEquals(".", XPathProcessor.join("a/b", "../.."));
+ }
}