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", "../..")); + } }