Skip to content

Commit

Permalink
Partial support for parsing XML namespaces (openrewrite#3925)
Browse files Browse the repository at this point in the history
* Partial support for parsing XML namespaces

* Adding namespace resolution

* Missing license header

* Adding recipes to search namespace URIs/prefixes

* Namespace shortcut methods on \'Xml.Document\'

* Change implementation to rely only on attributes

* Javadocs and cleanup

* Rename XmlNamespaceUtils & minor polish

Remove duplicate NonNull; see package-info.java
Validate argument not literal
Apply formatter

* Fix namespace search on XML hierarchy

* `ChangeNamespaceValue` now updates the `schemaLocation` attribute

* Consider namespaces on `SemanticallyEqual`.

* Suggestions from code review.

* Update rewrite-xml/src/main/java/org/openrewrite/xml/ChangeNamespaceValue.java

Co-authored-by: Knut Wannheden <knut.wannheden@gmail.com>

* Revert namespace comparison changes in `SemanticallyEqual`.

* Adding a Namespaces abstraction

* Add support for wildcard and local-name()

* Apply suggestions from code review

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Fix `Namespaces` mutability

* Adding an iterator implementation for `Namespaces`

* Replace `NotNull` with OpenRewrite's `NonNull`

* Polish.
Got rid of Namespaces class as it is mostly a thin wrapper around Map
Merge XmlNamespaceUtils into Xml
When you control the definition of the type creating a "utils" class for it makes those methods harder for users to discover than if they were defined on the class itself
Moved unit test to use AssertJ assertions to be consistent with our other tests
Removed Namespaces field from XPathMatcher because it was unused
Sentence-cased recipe metadata

---------

Co-authored-by: Knut Wannheden <tim@moderne.io>
Co-authored-by: Knut Wannheden <knut.wannheden@gmail.com>
Co-authored-by: Evie Lau <689163+evie-lau@users.noreply.github.com>
Co-authored-by: Evie Lau <github@evie.ee>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Sam Snyder <sam@moderne.io>
  • Loading branch information
7 people authored and Samuel Cox committed Jun 29, 2024
1 parent dc2b908 commit 72e64ab
Show file tree
Hide file tree
Showing 11 changed files with 980 additions and 50 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,26 @@
import lombok.Value;
import org.openrewrite.*;
import org.openrewrite.internal.ListUtils;
import org.openrewrite.internal.StringUtils;
import org.openrewrite.internal.lang.Nullable;
import org.openrewrite.marker.Markers;
import org.openrewrite.xml.tree.Xml;

import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static org.openrewrite.Tree.randomId;

@Value
@EqualsAndHashCode(callSuper = false)
public class ChangeNamespaceValue extends Recipe {
private static final String XMLNS_PREFIX = "xmlns";
private static final String VERSION_PREFIX = "version";
private static final String SCHEMA_LOCATION_MATCH_PATTERN = "(?m)(.*)(%s)(\\s+)(.*)";
private static final String SCHEMA_LOCATION_REPLACEMENT_PATTERN = "$1%s$3%s";
private static final String MSG_TAG_UPDATED = "msg-tag-updated";

@Override
public String getDisplayName() {
Expand Down Expand Up @@ -65,22 +77,73 @@ public String getDescription() {
String versionMatcher;

@Nullable
@Option(displayName = "Search All Namespaces",
@Option(displayName = "Search all namespaces",
description = "Specify whether evaluate all namespaces. Defaults to true",
example = "true",
required = false)
Boolean searchAllNamespaces;

@Nullable
@Option(displayName = "New Resource version",
description = "The new version of the resource",
example = "2.0")
String newVersion;

@Option(displayName = "Schema location",
description = "The new value to be used for the namespace schema location.",
example = "newfoo.bar.attribute.value.string",
required = false)
@Nullable
String newSchemaLocation;

public static final String XML_SCHEMA_INSTANCE_PREFIX = "xsi";
public static final String XML_SCHEMA_INSTANCE_URI = "http://www.w3.org/2001/XMLSchema-instance";

/**
* Find the tag that contains the declaration of the {@link #XML_SCHEMA_INSTANCE_URI} namespace.
*
* @param cursor the cursor to search from
* @return the tag that contains the declaration of the given namespace URI.
*/
public static Xml.Tag findTagContainingXmlSchemaInstanceNamespace(Cursor cursor) {
while (cursor != null) {
if (cursor.getValue() instanceof Xml.Document) {
return ((Xml.Document) cursor.getValue()).getRoot();
}
Xml.Tag tag = cursor.firstEnclosing(Xml.Tag.class);
if (tag != null) {
if (tag.getNamespaces().containsValue(XML_SCHEMA_INSTANCE_URI)) {
return tag;
}
}
cursor = cursor.getParent();
}

// Should never happen
throw new IllegalArgumentException("Could not find tag containing namespace '" + XML_SCHEMA_INSTANCE_URI + "' or the enclosing Xml.Document instance.");
}

@Override
public TreeVisitor<?, ExecutionContext> getVisitor() {
XPathMatcher elementNameMatcher = elementName != null ? new XPathMatcher(elementName) : null;
return new XmlIsoVisitor<ExecutionContext>() {
@Override
public Xml.Document visitDocument(Xml.Document document, ExecutionContext ctx) {
Xml.Document d = super.visitDocument(document, ctx);
if (ctx.pollMessage(MSG_TAG_UPDATED, false)) {
d = d.withRoot(addOrUpdateSchemaLocation(d.getRoot(), getCursor()));
}
return d;
}

@Override
public Xml.Tag visitTag(Xml.Tag tag, ExecutionContext ctx) {
Xml.Tag t = super.visitTag(tag, ctx);

if (matchesElementName(getCursor()) && matchesVersion(t)) {
t = t.withAttributes(ListUtils.map(t.getAttributes(), this::maybeReplaceNamespaceAttribute));
t = t.withAttributes(ListUtils.map(t.getAttributes(), this::maybeReplaceVersionAttribute));
ctx.putMessage(MSG_TAG_UPDATED, true);
}

return t;
Expand Down Expand Up @@ -114,6 +177,18 @@ private Xml.Attribute maybeReplaceNamespaceAttribute(Xml.Attribute attribute) {
return attribute;
}

private Xml.Attribute maybeReplaceVersionAttribute(Xml.Attribute attribute) {
if (isVersionAttribute(attribute) && newVersion != null) {
return attribute.withValue(
new Xml.Attribute.Value(attribute.getId(),
"",
attribute.getMarkers(),
attribute.getValue().getQuote(),
newVersion));
}
return attribute;
}

private boolean isXmlnsAttribute(Xml.Attribute attribute) {
boolean searchAll = searchAllNamespaces == null || Boolean.TRUE.equals(searchAllNamespaces);
return searchAll && attribute.getKeyAsString().startsWith(XMLNS_PREFIX) ||
Expand All @@ -129,6 +204,9 @@ private boolean isOldValue(Xml.Attribute attribute) {
}

private boolean isVersionMatch(Xml.Attribute attribute) {
if (versionMatcher == null) {
return true;
}
String[] versions = versionMatcher.split(",");
double dversion = Double.parseDouble(attribute.getValueAsString());
for (String splitVersion : versions) {
Expand All @@ -149,6 +227,87 @@ private boolean isVersionMatch(Xml.Attribute attribute) {
}
return false;
}

private Xml.Tag addOrUpdateSchemaLocation(Xml.Tag root, Cursor cursor) {
if (StringUtils.isBlank(newSchemaLocation)) {
return root;
}
Xml.Tag newRoot = maybeAddNamespace(root);
Optional<Xml.Attribute> maybeSchemaLocation = maybeGetSchemaLocation(cursor, newRoot);
if (maybeSchemaLocation.isPresent() && oldValue != null) {
newRoot = updateSchemaLocation(newRoot, maybeSchemaLocation.get());
} else if (!maybeSchemaLocation.isPresent()) {
newRoot = addSchemaLocation(newRoot);
}
return newRoot;
}

private Optional<Xml.Attribute> maybeGetSchemaLocation(Cursor cursor, Xml.Tag tag) {
Xml.Tag schemaLocationTag = findTagContainingXmlSchemaInstanceNamespace(cursor);
Map<String, String> namespaces = tag.getNamespaces();
for (Xml.Attribute attribute : schemaLocationTag.getAttributes()) {
String attributeNamespace = namespaces.get(Xml.extractNamespacePrefix(attribute.getKeyAsString()));
if(XML_SCHEMA_INSTANCE_URI.equals(attributeNamespace)
&& attribute.getKeyAsString().endsWith("schemaLocation")) {
return Optional.of(attribute);
}
}

return Optional.empty();
}

private Xml.Tag maybeAddNamespace(Xml.Tag root) {
Map<String, String> namespaces = root.getNamespaces();
if (namespaces.containsValue(newValue) && !namespaces.containsValue(XML_SCHEMA_INSTANCE_URI)) {
namespaces.put(XML_SCHEMA_INSTANCE_PREFIX, XML_SCHEMA_INSTANCE_URI);
root = root.withNamespaces(namespaces);
}
return root;
}

private Xml.Tag updateSchemaLocation(Xml.Tag newRoot, Xml.Attribute attribute) {
if(oldValue == null) {
return newRoot;
}
String oldSchemaLocation = attribute.getValueAsString();
Matcher pattern = Pattern.compile(String.format(SCHEMA_LOCATION_MATCH_PATTERN, Pattern.quote(oldValue)))
.matcher(oldSchemaLocation);
if (pattern.find()) {
String newSchemaLocationValue = pattern.replaceFirst(
String.format(SCHEMA_LOCATION_REPLACEMENT_PATTERN, newValue, newSchemaLocation)
);
Xml.Attribute newAttribute = attribute.withValue(attribute.getValue().withValue(newSchemaLocationValue));
newRoot = newRoot.withAttributes(ListUtils.map(newRoot.getAttributes(), a -> a == attribute ? newAttribute : a));
}
return newRoot;
}

private Xml.Tag addSchemaLocation(Xml.Tag newRoot) {
return newRoot.withAttributes(
ListUtils.concat(
newRoot.getAttributes(),
new Xml.Attribute(
randomId(),
" ",
Markers.EMPTY,
new Xml.Ident(
randomId(),
"",
Markers.EMPTY,
String.format("%s:schemaLocation", XML_SCHEMA_INSTANCE_PREFIX)
),
"",
new Xml.Attribute.Value(
randomId(),
"",
Markers.EMPTY,
Xml.Attribute.Value.Quote.Double,
String.format("%s %s", newValue, newSchemaLocation)
)
)
)
);
}
};
}
}
38 changes: 28 additions & 10 deletions rewrite-xml/src/main/java/org/openrewrite/xml/XPathMatcher.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
public class XPathMatcher {

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

private final String expression;
private final boolean startsWithSlash;
Expand Down Expand Up @@ -82,6 +82,9 @@ public boolean matches(Cursor cursor) {
if (part.charAt(index + 1) == '@') {
partWithCondition = part;
tagForCondition = path.get(i);
} else if (part.contains("(") && part.contains(")")) { //if is function
partWithCondition = part;
tagForCondition = path.get(i);
}
} else if (i < path.size() && i > 0 && parts[i - 1].endsWith("]")) {
String partBefore = parts[i - 1];
Expand All @@ -94,14 +97,16 @@ public boolean matches(Cursor cursor) {
partWithCondition = partBefore;
tagForCondition = path.get(parts.length - i);
}
} else if (part.endsWith(")")) { // is xpath method
// TODO: implement other xpath methods
}

String partName;

Matcher matcher;
if (tagForCondition != null && partWithCondition.endsWith("]") && (matcher = PATTERN.matcher(
partWithCondition)).matches()) {
String optionalPartName = matchesCondition(matcher, tagForCondition);
String optionalPartName = matchesCondition(matcher, tagForCondition, cursor);
if (optionalPartName == null) {
return false;
}
Expand Down Expand Up @@ -176,7 +181,7 @@ public boolean matches(Cursor cursor) {

Matcher matcher;
if (tag != null && part.endsWith("]") && (matcher = PATTERN.matcher(part)).matches()) {
String optionalPartName = matchesCondition(matcher, tag);
String optionalPartName = matchesCondition(matcher, tag, cursor);
if (optionalPartName == null) {
return false;
}
Expand All @@ -191,7 +196,7 @@ public boolean matches(Cursor cursor) {
"*".equals(part.substring(1)));
}

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

@Nullable
private String matchesCondition(Matcher matcher, Xml.Tag tag) {
private String matchesCondition(Matcher matcher, Xml.Tag tag, Cursor cursor) {
String name = matcher.group(1);
boolean isAttribute = Objects.equals(matcher.group(2), "@");
String selector = matcher.group(3);
String value = matcher.group(4);
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);

boolean matchCondition = false;
if (isAttribute) {
for (Xml.Attribute a : tag.getAttributes()) {
if (a.getKeyAsString().equals(selector) && a.getValueAsString().equals(value)) {
if ((a.getKeyAsString().equals(selector) || "*".equals(selector)) && a.getValueAsString().equals(value)) {
matchCondition = true;
break;
}
}
} else {
} 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 { // other [] conditions
for (Xml.Tag t : FindTags.find(tag, selector)) {
if (t.getValue().map(v -> v.equals(value)).orElse(false)) {
matchCondition = true;
Expand Down
Loading

0 comments on commit 72e64ab

Please sign in to comment.