Skip to content

Commit

Permalink
Add XPath support for namespace-uri() condition and attribute elements (
Browse files Browse the repository at this point in the history
#4287)

* Add support for namespace-uri() condition

* Update copyrights, add @OverRide

* add missing overrides

* Formatting, remove test that was accidentally added

* Improve attribute element matching. Add more XPath test coverage and TODO test cases.

* Formatting

* Handle namespaced attribute element with conditions

* Remove latest updated year in copyright

* Move splitter pattern to class field

---------

Co-authored-by: Tim te Beek <tim@moderne.io>
  • Loading branch information
evie-lau and timtebeek authored Jul 3, 2024
1 parent 34046a5 commit 28ad1cf
Show file tree
Hide file tree
Showing 4 changed files with 234 additions and 39 deletions.
111 changes: 75 additions & 36 deletions rewrite-xml/src/main/java/org/openrewrite/xml/XPathMatcher.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import org.openrewrite.internal.StringUtils;
import org.openrewrite.internal.lang.Nullable;
import org.openrewrite.xml.search.FindTags;
import org.openrewrite.xml.tree.Namespaced;
import org.openrewrite.xml.tree.Xml;

import java.util.*;
Expand All @@ -36,8 +37,9 @@
*/
public class XPathMatcher {

private static final Pattern XPATH_ELEMENT_SPLITTER = Pattern.compile("((?<=/)(?=/)|[^/\\[]|\\[[^]]*\\])+");
// Regular expression to support conditional tags like `plugin[artifactId='maven-compiler-plugin']` or foo[@bar='baz']
private static final Pattern PATTERN = Pattern.compile("([-\\w]+|\\*)\\[((local-name|namespace-uri)\\(\\)|(@)?([-\\w]+|\\*))='([-\\w.]+)']");
private static final Pattern PATTERN = Pattern.compile("(@)?([-:\\w]+|\\*)\\[((local-name|namespace-uri)\\(\\)|(@)?([-\\w]+|\\*))='(.*)']");

private final String expression;
private final boolean startsWithSlash;
Expand All @@ -48,7 +50,16 @@ public XPathMatcher(String expression) {
this.expression = expression;
startsWithSlash = expression.startsWith("/");
startsWithDoubleSlash = expression.startsWith("//");
parts = expression.substring(startsWithDoubleSlash ? 2 : startsWithSlash ? 1 : 0).split("/");
parts = splitOnXPathSeparator(expression.substring(startsWithDoubleSlash ? 2 : startsWithSlash ? 1 : 0));
}

private String[] splitOnXPathSeparator(String input) {
List<String> matches = new ArrayList<>();
Matcher m = XPATH_ELEMENT_SPLITTER.matcher(input);
while (m.find()) {
matches.add(m.group());
}
return matches.toArray(new String[0]);
}

/**
Expand Down Expand Up @@ -78,13 +89,17 @@ public boolean matches(Cursor cursor) {
if (index < 0) {
return false;
}
//if is Attribute
if (part.charAt(index + 1) == '@') {
partWithCondition = part;
tagForCondition = path.get(i);
} else if (part.contains("(") && part.contains(")")) { //if is function
if (part.startsWith("@")) { // is attribute selector
partWithCondition = part;
tagForCondition = path.get(i);
tagForCondition = i > 0 ? path.get(i - 1) : path.get(i);
} else { // is element selector
if (part.charAt(index + 1) == '@') { // is Attribute condition
partWithCondition = part;
tagForCondition = path.get(i);
} else if (part.contains("(") && part.contains(")")) { // is function condition
partWithCondition = part;
tagForCondition = path.get(i);
}
}
} else if (i < path.size() && i > 0 && parts[i - 1].endsWith("]")) {
String partBefore = parts[i - 1];
Expand All @@ -102,24 +117,30 @@ public boolean matches(Cursor cursor) {
}

String partName;
boolean matchedCondition = false;

Matcher matcher;
if (tagForCondition != null && partWithCondition.endsWith("]") && (matcher = PATTERN.matcher(
partWithCondition)).matches()) {
String optionalPartName = matchesCondition(matcher, tagForCondition, cursor);
String optionalPartName = matchesElementWithConditionFunction(matcher, tagForCondition, cursor);
if (optionalPartName == null) {
return false;
}
partName = optionalPartName;
matchedCondition = true;
} else {
partName = null;
}

if (part.startsWith("@")) {
if (!(cursor.getValue() instanceof Xml.Attribute &&
(((Xml.Attribute) cursor.getValue()).getKeyAsString().equals(part.substring(1))) ||
"*".equals(part.substring(1)))) {
return false;
if (!matchedCondition) {
if (!(cursor.getValue() instanceof Xml.Attribute)) {
return false;
}
Xml.Attribute attribute = cursor.getValue();
if (!attribute.getKeyAsString().equals(part.substring(1)) && !"*".equals(part.substring(1))) {
return false;
}
}

pathIndex--;
Expand All @@ -145,7 +166,7 @@ public boolean matches(Cursor cursor) {
Collections.reverse(path);

// Deal with the two forward slashes in the expression; works, but I'm not proud of it.
if (expression.contains("//") && Arrays.stream(parts).anyMatch(StringUtils::isBlank)) {
if (expression.contains("//") && !expression.contains("://") && Arrays.stream(parts).anyMatch(StringUtils::isBlank)) {
int blankPartIndex = Arrays.asList(parts).indexOf("");
int doubleSlashIndex = expression.indexOf("//");

Expand Down Expand Up @@ -176,24 +197,30 @@ public boolean matches(Cursor cursor) {
for (int i = 0; i < parts.length; i++) {
String part = parts[i];

Xml.Tag tag = i < path.size() ? path.get(i) : null;
int isAttr = part.startsWith("@") ? 1 : 0;
Xml.Tag tag = i - isAttr < path.size() ? path.get(i - isAttr) : null;
String partName;
boolean matchedCondition = false;

Matcher matcher;
if (tag != null && part.endsWith("]") && (matcher = PATTERN.matcher(part)).matches()) {
String optionalPartName = matchesCondition(matcher, tag, cursor);
String optionalPartName = matchesElementWithConditionFunction(matcher, tag, cursor);
if (optionalPartName == null) {
return false;
}
partName = optionalPartName;
matchedCondition = true;
} else {
partName = part;
}

if (part.startsWith("@")) {
if (matchedCondition) {
return true;
}
return cursor.getValue() instanceof Xml.Attribute &&
(((Xml.Attribute) cursor.getValue()).getKeyAsString().equals(part.substring(1)) ||
"*".equals(part.substring(1)));
(((Xml.Attribute) cursor.getValue()).getKeyAsString().equals(part.substring(1)) ||
"*".equals(part.substring(1)));
}

if (path.size() < i + 1 || (tag != null && !tag.getName().equals(partName) && !partName.equals("*") && !"*".equals(part))) {
Expand All @@ -206,32 +233,32 @@ public boolean matches(Cursor cursor) {
}

@Nullable
private String matchesCondition(Matcher matcher, Xml.Tag tag, Cursor cursor) {
String name = matcher.group(1);
boolean isAttribute = matcher.group(4) != null; // either group4 != null, or group 2 startsWith @
String selector = isAttribute ? matcher.group(5) : matcher.group(2);
boolean isFunction = selector.endsWith("()");
String value = matcher.group(6);
private String matchesElementWithConditionFunction(Matcher matcher, Xml.Tag tag, Cursor cursor) {
boolean isAttributeElement = matcher.group(1) != null;
String element = matcher.group(2);
boolean isAttributeCondition = matcher.group(5) != null; // either group4 != null, or group 2 startsWith @
String selector = isAttributeCondition ? matcher.group(6) : matcher.group(3);
boolean isFunctionCondition = selector.endsWith("()");
String value = matcher.group(7);

boolean matchCondition = false;
if (isAttribute) {
if (isAttributeCondition) {
for (Xml.Attribute a : tag.getAttributes()) {
if ((a.getKeyAsString().equals(selector) || "*".equals(selector)) && a.getValueAsString().equals(value)) {
matchCondition = true;
break;
}
}
} else if (isFunction) {
if (!name.equals("*") && !tag.getLocalName().equals(name)) {
matchCondition = false;
} else if (selector.equals("local-name()")) {
if (tag.getLocalName().equals(value)) {
matchCondition = true;
}
} else if (selector.equals("namespace-uri()")) {
if (tag.getNamespaceUri(cursor).get().equals(value)) {
matchCondition = true;
} else if (isFunctionCondition) {
if (isAttributeElement) {
for (Xml.Attribute a : tag.getAttributes()) {
if (matchesElementAndFunction(a, cursor, element, selector, value)) {
matchCondition = true;
break;
}
}
} else {
matchCondition = matchesElementAndFunction(tag, cursor, element, selector, value);
}
} else { // other [] conditions
for (Xml.Tag t : FindTags.find(tag, selector)) {
Expand All @@ -242,6 +269,18 @@ private String matchesCondition(Matcher matcher, Xml.Tag tag, Cursor cursor) {
}
}

return matchCondition ? name : null;
return matchCondition ? element : null;
}

private static boolean matchesElementAndFunction(Namespaced tagOrAttribute, Cursor cursor, String element, String selector, String value) {
if (!element.equals("*") && !tagOrAttribute.getName().equals(element)) {
return false;
} else if (selector.equals("local-name()")) {
return tagOrAttribute.getLocalName().equals(value);
} else if (selector.equals("namespace-uri()")) {
Optional<String> nsUri = tagOrAttribute.getNamespaceUri(cursor);
return nsUri.isPresent() && nsUri.get().equals(value);
}
return false;
}
}
33 changes: 33 additions & 0 deletions rewrite-xml/src/main/java/org/openrewrite/xml/tree/Namespaced.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright 2024 the original author or authors.
* <p>
* 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
* <p>
* https://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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.
*/
package org.openrewrite.xml.tree;

import org.openrewrite.Cursor;

import java.util.Map;
import java.util.Optional;

public interface Namespaced extends Xml {
String getName();

String getLocalName();

Optional<String> getNamespacePrefix();

Optional<String> getNamespaceUri(Cursor cursor);

Map<String, String> getAllNamespaces(Cursor cursor);
}
64 changes: 62 additions & 2 deletions rewrite-xml/src/main/java/org/openrewrite/xml/tree/Xml.java
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ public <P> Xml acceptXml(XmlVisitor<P> v, P p) {
@SuppressWarnings("unused")
@Value
@EqualsAndHashCode(callSuper = false, onlyExplicitlyIncluded = true)
class Tag implements Xml, Content {
class Tag implements Xml, Content, Namespaced {
@EqualsAndHashCode.Include
@With
UUID id;
Expand Down Expand Up @@ -345,6 +345,7 @@ public Map<String, String> getNamespaces() {
* @param cursor the cursor to search from
* @return a map containing all namespaces defined in the current scope, including all parent scopes.
*/
@Override
public Map<String, String> getAllNamespaces(Cursor cursor) {
Map<String, String> namespaces = getNamespaces();
while (cursor != null) {
Expand Down Expand Up @@ -615,13 +616,15 @@ public Tag withContent(@Nullable List<? extends Content> content) {
/**
* @return The local name for this tag, without any namespace prefix.
*/
@Override
public String getLocalName() {
return extractLocalName(name);
}

/**
* @return The namespace prefix for this tag, if any.
*/
@Override
public Optional<String> getNamespacePrefix() {
String extractedNamespacePrefix = extractNamespacePrefix(name);
return Optional.ofNullable(StringUtils.isNotEmpty(extractedNamespacePrefix) ? extractedNamespacePrefix : null);
Expand All @@ -630,6 +633,7 @@ public Optional<String> getNamespacePrefix() {
/**
* @return The namespace URI for this tag, if any.
*/
@Override
public Optional<String> getNamespaceUri(Cursor cursor) {
Optional<String> maybeNamespacePrefix = getNamespacePrefix();
return maybeNamespacePrefix.flatMap(s -> Optional.ofNullable(getAllNamespaces(cursor).get(s)));
Expand Down Expand Up @@ -688,7 +692,7 @@ public String toString() {
@lombok.Value
@EqualsAndHashCode(callSuper = false, onlyExplicitlyIncluded = true)
@With
class Attribute implements Xml {
class Attribute implements Xml, Namespaced {
@EqualsAndHashCode.Include
UUID id;

Expand Down Expand Up @@ -755,6 +759,62 @@ public String getValueAsString() {
return value.getValue();
}

@Override
public String getName() {
return key.getName();
}

/**
* @return The local name for this attribute, without any namespace prefix.
*/
@Override
public String getLocalName() {
return extractLocalName(getKeyAsString());
}

/**
* @return The namespace prefix for this attribute, if any.
*/
@Override
public Optional<String> getNamespacePrefix() {
String extractedNamespacePrefix = extractNamespacePrefix(getKeyAsString());
return Optional.ofNullable(StringUtils.isNotEmpty(extractedNamespacePrefix) ? extractedNamespacePrefix : null);
}

/**
* @return The namespace URI for this attribute, if any.
*/
@Override
public Optional<String> getNamespaceUri(Cursor cursor) {
Optional<String> maybeNamespacePrefix = getNamespacePrefix();
return maybeNamespacePrefix.flatMap(s -> Optional.ofNullable(getAllNamespaces(cursor).get(s)));
}

/**
* Gets a map containing all namespaces defined in the current scope, including all parent scopes.
*
* @param cursor the cursor to search from
* @return a map containing all namespaces defined in the current scope, including all parent scopes.
*/
@Override
public Map<String, String> getAllNamespaces(Cursor cursor) {
Map<String, String> namespaces = new HashMap<>();
while (cursor != null) {
Xml.Tag enclosing = cursor.firstEnclosing(Xml.Tag.class);
if (enclosing != null) {
for (Map.Entry<String, String> ns : enclosing.getNamespaces().entrySet()) {
if (namespaces.containsValue(ns.getKey())) {
throw new IllegalStateException(java.lang.String.format("Cannot have two namespaces with the same prefix (%s): '%s' and '%s'", ns.getKey(), namespaces.get(ns.getKey()), ns.getValue()));
}
namespaces.put(ns.getKey(), ns.getValue());
}
}
cursor = cursor.getParent();
}

return namespaces;
}

@Override
public String toString() {
return getKeyAsString() + "=" + getValueAsString();
Expand Down
Loading

0 comments on commit 28ad1cf

Please sign in to comment.