Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/main/java/eu/europa/ted/eforms/xpath/XPathAnchor.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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;
}
}
14 changes: 14 additions & 0 deletions src/main/java/eu/europa/ted/eforms/xpath/XPathInfo.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public class XPathInfo {
private LinkedList<XPathStep> steps = new LinkedList<>();
private String pathToLastElement;
private String attributeName;
private XPathAnchor anchor = XPathAnchor.RELATIVE;

public List<XPathStep> getSteps() {
return steps;
Expand All @@ -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;
}
Expand Down
26 changes: 26 additions & 0 deletions src/main/java/eu/europa/ted/eforms/xpath/XPathListenerImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,11 +26,13 @@ class XPathListenerImpl extends XPath20BaseListener {
private CharStream inputStream;
private LinkedList<StepInfo> 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);
Expand Down Expand Up @@ -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++;
Expand Down
36 changes: 29 additions & 7 deletions src/main/java/eu/europa/ted/eforms/xpath/XPathProcessor.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -34,10 +35,20 @@ public static String join(final String first, final String second) {
return first;
}

LinkedList<XPathStep> firstPartSteps = new LinkedList<>(parse(first).getSteps());
final XPathInfo firstPart = parse(first);
LinkedList<XPathStep> firstPartSteps = new LinkedList<>(firstPart.getSteps());
LinkedList<XPathStep> 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) {
Expand Down Expand Up @@ -97,7 +108,7 @@ private static String getContextualizedXpath(Queue<XPathStep> 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.
Expand Down Expand Up @@ -129,15 +140,26 @@ private static String getContextualizedXpath(Queue<XPathStep> contextQueue,
}

private static String getJoinedXPath(LinkedList<XPathStep> first,
final LinkedList<XPathStep> second) {
final LinkedList<XPathStep> second, final XPathAnchor anchor) {
List<String> 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<XPathStep> steps = new ArrayList<>(first);
steps.addAll(second);

return steps.stream().map(s -> s.toString()).collect(Collectors.joining("/"));
}
}
10 changes: 10 additions & 0 deletions src/main/java/eu/europa/ted/eforms/xpath/XPathStep.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
60 changes: 60 additions & 0 deletions src/test/java/eu/europa/ted/eforms/xpath/XPathProcessorTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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", "../.."));
}
}
Loading