Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add XPath support for namespace-uri() condition and attribute elements #4287

Merged
merged 12 commits into from
Jul 3, 2024
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;
evie-lau marked this conversation as resolved.
Show resolved Hide resolved

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() {
evie-lau marked this conversation as resolved.
Show resolved Hide resolved
return key.getName();
}

/**
* @return The local name for this attribute, without any namespace prefix.
*/
evie-lau marked this conversation as resolved.
Show resolved Hide resolved
@Override
public String getLocalName() {
return extractLocalName(getKeyAsString());
}

/**
* @return The namespace prefix for this attribute, if any.
*/
evie-lau marked this conversation as resolved.
Show resolved Hide resolved
@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.
*/
evie-lau marked this conversation as resolved.
Show resolved Hide resolved
@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.
*/
evie-lau marked this conversation as resolved.
Show resolved Hide resolved
@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