diff --git a/rewrite-xml/src/main/java/org/openrewrite/xml/AddOrUpdateChildTag.java b/rewrite-xml/src/main/java/org/openrewrite/xml/AddOrUpdateChildTag.java new file mode 100644 index 00000000000..5b44051649c --- /dev/null +++ b/rewrite-xml/src/main/java/org/openrewrite/xml/AddOrUpdateChildTag.java @@ -0,0 +1,89 @@ +/* + * Copyright 2022 the original author or authors. + *

+ * 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 + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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 = "true") + @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 validate() { + Validated 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 getVisitor() { + return new XmlVisitor() { + 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); + } + }; + } +} diff --git a/rewrite-xml/src/main/java/org/openrewrite/xml/XPathMatcher.java b/rewrite-xml/src/main/java/org/openrewrite/xml/XPathMatcher.java index b3c184b9038..c3fbd8fe165 100644 --- a/rewrite-xml/src/main/java/org/openrewrite/xml/XPathMatcher.java +++ b/rewrite-xml/src/main/java/org/openrewrite/xml/XPathMatcher.java @@ -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) { @@ -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("["); @@ -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; @@ -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; } @@ -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); diff --git a/rewrite-xml/src/test/java/org/openrewrite/xml/AddOrUpdateChildTagTest.java b/rewrite-xml/src/test/java/org/openrewrite/xml/AddOrUpdateChildTagTest.java new file mode 100644 index 00000000000..49bdce28b09 --- /dev/null +++ b/rewrite-xml/src/test/java/org/openrewrite/xml/AddOrUpdateChildTagTest.java @@ -0,0 +1,496 @@ +/* + * Copyright 2024 the original author or authors. + *

+ * 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 + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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 org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullSource; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.xml.Assertions.xml; + +class AddOrUpdateChildTagTest implements RewriteTest { + @ParameterizedTest + @NullSource + @CsvSource({"true", "false"}) + void addsTagEverywhereWhenAbsent(Boolean replaceExisting) { + rewriteRun(spec -> spec.recipe(new AddOrUpdateChildTag( + "/project//plugin[groupId='org.apache.maven.plugins' and artifactId='maven-resources-plugin']" + + "//configuration", + "true", + replaceExisting)), + xml( + """ + + + 4.0.0 + com.example + my-project + 1.0 + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + UTF-8 + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + + UTF-8 + + + + + + + + + profile1 + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + + UTF-8 + + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + UTF-8 + + + + + + + + """, + """ + + + 4.0.0 + com.example + my-project + 1.0 + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + UTF-8 + true + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + + UTF-8 + true + + + + + + + + + profile1 + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + + UTF-8 + true + + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + UTF-8 + true + + + + + + + + """ + ) + ); + } + + @ParameterizedTest + @NullSource + @CsvSource("true") + void updateTagEverywhere(Boolean replaceExisting) { + rewriteRun(spec -> spec.recipe(new AddOrUpdateChildTag( + "/project//plugin[groupId='org.apache.maven.plugins' and artifactId='maven-resources-plugin']" + + "//configuration", + "true", + replaceExisting)), + xml( + """ + + + 4.0.0 + com.example + my-project + 1.0 + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + UTF-8 + false + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + UTF-8 + false + + + + + + + profile1 + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + UTF-8 + false + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + UTF-8 + false + + + + + + + + """, + """ + + + 4.0.0 + com.example + my-project + 1.0 + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + UTF-8 + true + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + UTF-8 + true + + + + + + + profile1 + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + UTF-8 + true + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + UTF-8 + true + + + + + + + + """ + ) + ); + } + + @Test + void dontUpdateIfReplaceIsDisabled() { + rewriteRun(spec -> spec.recipe(new AddOrUpdateChildTag( + "/project//plugin[groupId='org.apache.maven.plugins' and artifactId='maven-resources-plugin']" + + "//configuration", + "true", + false)), + xml( + """ + + + 4.0.0 + com.example + my-project + 1.0 + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + UTF-8 + false + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + UTF-8 + false + + + + + + + profile1 + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + UTF-8 + false + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + UTF-8 + false + + + + + + + + """ + ) + ); + } + + @ParameterizedTest + @NullSource + @CsvSource({"true", "false"}) + void dontTouchAnythingElse(Boolean replaceExisting) { + rewriteRun(spec -> spec.recipe(new AddOrUpdateChildTag( + "/project//plugin[groupId='org.apache.maven.plugins' and artifactId='maven-resources-plugin']" + + "//configuration", + "true", + replaceExisting)), + xml( + """ + + + 4.0.0 + com.example + my-project + 1.0 + + + + + + org.apache.maven.plugins + maven-other-plugin + 3.3.1 + + UTF-8 + false + + + + + + + org.apache.other.plugins + maven-resources-plugin + 3.3.1 + + UTF-8 + false + + + + + + + profile1 + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + + + + org.apache.maven.plugins + maven-other-plugin + 3.3.1 + + UTF-8 + false + + + + + + + + """ + ) + ); + } +} diff --git a/rewrite-xml/src/test/java/org/openrewrite/xml/XPathMatcherTest.java b/rewrite-xml/src/test/java/org/openrewrite/xml/XPathMatcherTest.java index f369bfda2c2..31b5363800b 100755 --- a/rewrite-xml/src/test/java/org/openrewrite/xml/XPathMatcherTest.java +++ b/rewrite-xml/src/test/java/org/openrewrite/xml/XPathMatcherTest.java @@ -118,7 +118,13 @@ void matchAbsolute() { assertThat(match("/dependencies/dependency", xmlDoc)).isTrue(); assertThat(match("/dependencies/*/artifactId", xmlDoc)).isTrue(); assertThat(match("/dependencies/*", xmlDoc)).isTrue(); + assertThat(match("/dependencies//dependency", xmlDoc)).isTrue(); + assertThat(match("/dependencies//dependency//groupId", xmlDoc)).isTrue(); + + // negative matches assertThat(match("/dependencies/dne", xmlDoc)).isFalse(); + assertThat(match("/dependencies//dne", xmlDoc)).isFalse(); + assertThat(match("/dependencies//dependency//dne", xmlDoc)).isFalse(); } @Test @@ -127,6 +133,17 @@ void matchAbsoluteAttribute() { assertThat(match("/dependencies/dependency/artifactId/@scope", xmlDoc)).isTrue(); assertThat(match("/dependencies/dependency/artifactId/@*", xmlDoc)).isTrue(); assertThat(match("/dependencies/dependency/groupId/@*", xmlDoc)).isFalse(); + assertThat(match("/dependencies//dependency//@scope", xmlDoc)).isTrue(); + assertThat(match("/dependencies//dependency//artifactId//@scope", xmlDoc)).isTrue(); + assertThat(match("/dependencies//dependency//@*", xmlDoc)).isTrue(); + assertThat(match("/dependencies//dependency//artifactId//@*", xmlDoc)).isTrue(); + + // negative matches + assertThat(match("/dependencies/dependency/artifactId/@dne", xmlDoc)).isFalse(); + assertThat(match("/dependencies/dependency/artifactId/@dne", xmlDoc)).isFalse(); + assertThat(match("/dependencies//dependency//@dne", xmlDoc)).isFalse(); + assertThat(match("/dependencies//dependency//artifactId//@dne", xmlDoc)).isFalse(); + } @Test @@ -136,8 +153,6 @@ void matchRelative() { assertThat(match("//dependency", xmlDoc)).isTrue(); assertThat(match("dependency/*", xmlDoc)).isTrue(); assertThat(match("dne", xmlDoc)).isFalse(); - assertThat(match("/dependencies//dependency", xmlDoc)).isTrue(); - assertThat(match("/dependencies//dependency/groupId", xmlDoc)).isTrue(); } @Test @@ -147,6 +162,51 @@ void matchRelativeAttribute() { assertThat(match("//dependency/artifactId/@scope", xmlDoc)).isTrue(); } + @Test + void matchNestedElementsWithSameName() { + var xml = new XmlParser().parse( + """ + + + + + auie + + + + + """ + ).toList().get(0); + + // no / at start + assertThat(match("element/test", xml)).isTrue(); + assertThat(match("element[@foo='bar']/test", xml)).isTrue(); + assertThat(match("element[@foo='baz']/test", xml)).isFalse(); + assertThat(match("element/@qux", xml)).isTrue(); + assertThat(match("dne[@foo='bar']/test", xml)).isFalse(); + + // // at start + assertThat(match("//element/test", xml)).isTrue(); + assertThat(match("//element[@foo='bar']/test", xml)).isTrue(); + assertThat(match("//element[@foo='baz']/test", xml)).isFalse(); + assertThat(match("//element/@qux", xml)).isTrue(); + assertThat(match("//dne[@foo='bar']/test", xml)).isFalse(); + + // TODO // in the middle without / (or with //) at start (not currently supported) +// assertThat(match("root//element/test", xml)).isTrue(); +// assertThat(match("root//element[@foo='bar']/test", xml)).isTrue(); +// assertThat(match("root//element[@foo='baz']/test", xml)).isFalse(); +// assertThat(match("root//element/@qux", xml)).isTrue(); +// assertThat(match("root//dne[@foo='bar']/test", xml)).isFalse(); + + // // in the middle with / at start + assertThat(match("/root//element/test", xml)).isTrue(); + assertThat(match("/root//element[@foo='bar']/test", xml)).isTrue(); + assertThat(match("/root//element[@foo='baz']/test", xml)).isFalse(); + assertThat(match("/root//element/@qux", xml)).isTrue(); + assertThat(match("/root//dne[@foo='bar']/test", xml)).isFalse(); + } + @Test void matchPom() { assertThat(match("/project/build/plugins/plugin/configuration/source", @@ -163,8 +223,10 @@ void matchPom() { pomXml1)).isTrue(); assertThat(match("/project/build//plugins/plugin/configuration/source", pomXml2)).isTrue(); -// assertThat(match("/project/build//plugin/configuration/source", pomXml2)).isTrue(); // TODO: seems parser only handles // up to 1 level -// assertThat(match("/project//configuration/source", pomXml2)).isTrue(); // TODO: was already failing previously + // skip 2+ levels with // + assertThat(match("/project/build//plugin/configuration/source", pomXml2)).isTrue(); + assertThat(match("/project//configuration/source", pomXml2)).isTrue(); + assertThat(match("/project//plugin//source", pomXml2)).isTrue(); } private final SourceFile attributeXml = new XmlParser().parse( @@ -203,7 +265,7 @@ void wildcards() { // relative xpath with wildcard element assertThat(match("//*[@foo='bar']", attributeXml)).isTrue(); assertThat(match("//*[@foo='baz']", attributeXml)).isFalse(); -// assertThat(match("//*[foo='bar']", attributeXml)).isFalse(); // TODO: fix relative xpath with condition + assertThat(match("//*[foo='bar']", attributeXml)).isFalse(); assertThat(match("//*[foo='baz']", attributeXml)).isTrue(); } @@ -220,7 +282,7 @@ void relativePathsWithConditions() { """ ).toList().get(0); -// assertThat(match("//element1[foo='bar']", xml)).isFalse(); // TODO: fix - was already failing before * changes + assertThat(match("//element1[foo='bar']", xml)).isFalse(); assertThat(match("//element1[foo='baz']", xml)).isTrue(); assertThat(match("//element1[@foo='bar']", xml)).isTrue(); assertThat(match("//element1[foo='baz']/test", xml)).isTrue(); @@ -275,8 +337,7 @@ void matchLocalNameFunctionCondition() { assertThat(match("//ns2:element2[local-name()='element2']", namespacedXml)).isTrue(); assertThat(match("//dne[local-name()='dne']", namespacedXml)).isFalse(); - // TODO: fix mid-path // with condition -// assertThat(match("/root//element1[local-name()='element1']", namespacedXml)).isTrue(); + assertThat(match("/root//element1[local-name()='element1']", namespacedXml)).isTrue(); } @Test @@ -300,9 +361,8 @@ void matchAttributeCondition() { assertThat(match("//element1[@*='dne']", namespacedXml)).isFalse(); assertThat(match("/root/element1[@*='content3']", namespacedXml)).isTrue(); assertThat(match("/root/element1[@*='dne']", namespacedXml)).isFalse(); - // TODO: fix mid-path // match with condition -// assertThat(match("/root//element1[@*='content3']", namespacedXml)).isTrue(); -// assertThat(match("/root//element1[@*='dne']", namespacedXml)).isFalse(); + assertThat(match("/root//element1[@*='content3']", namespacedXml)).isTrue(); + assertThat(match("/root//element1[@*='dne']", namespacedXml)).isFalse(); } @Test @@ -327,9 +387,8 @@ void matchAttributeElement() { assertThat(match("/root/parent/element3/@ns3:attr[namespace-uri()='http://www.example.com/namespace3']", namespacedXml)).isTrue(); assertThat(match("//element3/@ns3:attr[namespace-uri()='http://www.example.com/namespace3']", namespacedXml)).isTrue(); - // TODO: fix mid-path // match with attribute element -// assertThat(match("/root//element1/@*", namespacedXml)).isTrue(); -// assertThat(match("/root//element1/@*[namespace-uri()='http://www.example.com/namespace3']", namespacedXml)).isTrue(); + assertThat(match("/root//element1/@*", namespacedXml)).isTrue(); + assertThat(match("/root//element1/@*[namespace-uri()='http://www.example.com/namespace3']", namespacedXml)).isTrue(); } @Test