Skip to content

Commit

Permalink
Expose AddOrUpdateChild as a recipe using XPath + child XML (#4533)
Browse files Browse the repository at this point in the history
* Fix all XPathMatcherTest TODO's + handle nested elements with the same name

* More XPath matching fixes

- paths with multiple occurrences of //
- // preceding @Attribute

* Expose AddOrUpdateChild as a recipe using XPath

* Added replaceExisting flag
  • Loading branch information
DidierLoiseau authored Oct 4, 2024
1 parent 5005d74 commit c307b31
Show file tree
Hide file tree
Showing 4 changed files with 705 additions and 44 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright 2022 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;

import lombok.EqualsAndHashCode;
import lombok.Value;
import org.intellij.lang.annotations.Language;
import org.jspecify.annotations.Nullable;
import org.openrewrite.*;
import org.openrewrite.xml.tree.Xml;

@Value
@EqualsAndHashCode(callSuper = false)
public class AddOrUpdateChildTag extends Recipe {

@Option(displayName = "Parent XPath",
description = "XPath identifying the parent to which a child tag must be added",
example = "/project//plugin//configuration")
@Language("xpath")
String parentXPath;

@Option(displayName = "New child tag",
description = "The XML of the new child to add or update on the parent tag.",
example = "<skip>true</skip>")
@Language("xml")
String newChildTag;

@Option(displayName = "Replace existing child",
description = "Set to `false` to not replace the child tag if it already exists. Defaults to true.",
required = false)
@Nullable
Boolean replaceExisting;

@Override
public String getDisplayName() {
return "Add or update child tag";
}

@Override
public String getDescription() {
return "Adds or updates a child element below the parent(s) matching the provided `parentXPath` expression. " +
"If a child with the same name already exists, it will be replaced by default. Otherwise, a new child will be added. " +
"This ensures idempotent behaviour.";
}

@Override
public Validated<Object> validate() {
Validated<Object> validated = super.validate()
.and(Validated.notBlank("parentXPath", parentXPath))
.and(Validated.notBlank("newChildTag", newChildTag));
try {
Xml.Tag.build(newChildTag);
} catch (Exception e) {
validated = validated.and(Validated.invalid("newChildTag", newChildTag, "Invalid XML for child tag", e));
}
return validated;
}

@Override
public TreeVisitor<?, ExecutionContext> getVisitor() {
return new XmlVisitor<ExecutionContext>() {
private final XPathMatcher xPathMatcher = new XPathMatcher(parentXPath);

@Override
public Xml visitTag(Xml.Tag tag, ExecutionContext ctx) {
if (xPathMatcher.matches(getCursor())) {
Xml.Tag newChild = Xml.Tag.build(newChildTag);
if (replaceExisting == null || replaceExisting || !tag.getChild(newChild.getName()).isPresent()) {
return AddOrUpdateChild.addOrUpdateChild(tag, newChild, getCursor().getParentOrThrow());
}
}
return super.visitTag(tag, ctx);
}
};
}
}
77 changes: 47 additions & 30 deletions rewrite-xml/src/main/java/org/openrewrite/xml/XPathMatcher.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,14 @@ public class XPathMatcher {
private final boolean startsWithSlash;
private final boolean startsWithDoubleSlash;
private final String[] parts;
private final long tagMatchingParts;

public XPathMatcher(String expression) {
this.expression = expression;
startsWithSlash = expression.startsWith("/");
startsWithDoubleSlash = expression.startsWith("//");
parts = splitOnXPathSeparator(expression.substring(startsWithDoubleSlash ? 2 : startsWithSlash ? 1 : 0));
tagMatchingParts = Arrays.stream(parts).filter(part -> !part.isEmpty() && !part.startsWith("@")).count();
}

private String[] splitOnXPathSeparator(String input) {
Expand Down Expand Up @@ -92,18 +94,8 @@ public boolean matches(Cursor cursor) {
if (index < 0) {
return false;
}
if (part.startsWith("@")) { // is attribute selector
partWithCondition = part;
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);
}
}
partWithCondition = part;
tagForCondition = path.get(pathIndex);
} else if (i < path.size() && i > 0 && parts[i - 1].endsWith("]")) {
String partBefore = parts[i - 1];
int index = partBefore.indexOf("[");
Expand All @@ -117,6 +109,7 @@ public boolean matches(Cursor cursor) {
}
} else if (part.endsWith(")")) { // is xpath method
// TODO: implement other xpath methods
throw new UnsupportedOperationException("XPath methods are not supported");
}

String partName;
Expand Down Expand Up @@ -164,36 +157,42 @@ public boolean matches(Cursor cursor) {
}
}

return startsWithSlash || path.size() - pathIndex <= 1;
// we have matched the whole XPath, and it does not start with the root
return true;
} else {
Collections.reverse(path);

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

if (path.size() > blankPartIndex && path.size() >= parts.length - 1) {
String newExpression;
if (Objects.equals(path.get(blankPartIndex).getName(), parts[blankPartIndex + 1])) {
newExpression = String.format(
"%s/%s",
expression.substring(0, doubleSlashIndex),
expression.substring(doubleSlashIndex + 2)
);
} else {
newExpression = String.format(
"%s/%s/%s",
expression.substring(0, doubleSlashIndex),
path.get(blankPartIndex).getName(),
expression.substring(doubleSlashIndex + 2)
);
if (path.size() > blankPartIndex && path.size() >= tagMatchingParts) {
Xml.Tag blankPartTag = path.get(blankPartIndex);
String part = parts[blankPartIndex + 1];
Matcher matcher = ELEMENT_WITH_CONDITION_PATTERN.matcher(part);
if (matcher.matches() ?
matchesElementWithConditionFunction(matcher, blankPartTag, cursor) != null :
Objects.equals(blankPartTag.getName(), part)) {
if (matchesWithoutDoubleSlashesAt(cursor, doubleSlashIndex)) {
return true;
}
// fall-through: maybe we can skip this element and match further down
}
String newExpression = String.format(
// the // here allows to skip several levels of nested elements
"%s/%s//%s",
expression.substring(0, doubleSlashIndex),
blankPartTag.getName(),
expression.substring(doubleSlashIndex + 2)
);
return new XPathMatcher(newExpression).matches(cursor);
} else if (path.size() == tagMatchingParts) {
return matchesWithoutDoubleSlashesAt(cursor, doubleSlashIndex);
}
}

if (parts.length > path.size() + 1) {
if (tagMatchingParts > path.size()) {
return false;
}

Expand Down Expand Up @@ -235,6 +234,24 @@ public boolean matches(Cursor cursor) {
}
}

private boolean matchesWithoutDoubleSlashesAt(Cursor cursor, int doubleSlashIndex) {
String newExpression = String.format(
"%s/%s",
expression.substring(0, doubleSlashIndex),
expression.substring(doubleSlashIndex + 2)
);
return new XPathMatcher(newExpression).matches(cursor);
}

/**
* Checks that the given {@code tag} matches the XPath part represented by {@code matcher}.
*
* @param matcher an XPath part matcher for {@link #ELEMENT_WITH_CONDITION_PATTERN}
* @param tag a tag to match
* @param cursor the cursor we are trying to match
* @return the element name specified before the condition of the part
* (either {@code tag.getName()}, {@code "*"} or an attribute name) or {@code null} if the tag did not match
*/
private @Nullable String matchesElementWithConditionFunction(Matcher matcher, Xml.Tag tag, Cursor cursor) {
boolean isAttributeElement = matcher.group(1) != null;
String element = matcher.group(2);
Expand Down
Loading

0 comments on commit c307b31

Please sign in to comment.