diff --git a/build.gradle.kts b/build.gradle.kts index 8325cd360..f49fddc73 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -75,6 +75,7 @@ val rewriteVersion = rewriteRecipe.rewriteVersion.get() dependencies { implementation(platform("org.openrewrite:rewrite-bom:${rewriteVersion}")) implementation("org.openrewrite:rewrite-java") + implementation("org.openrewrite:rewrite-xml") implementation("org.openrewrite:rewrite-gradle") implementation("org.openrewrite:rewrite-maven") implementation("org.openrewrite.recipe:rewrite-java-dependencies:${rewriteVersion}") @@ -98,6 +99,7 @@ dependencies { testImplementation("org.openrewrite:rewrite-java-25") testImplementation("org.openrewrite:rewrite-groovy") testImplementation("org.openrewrite:rewrite-test") + testImplementation("org.openrewrite:rewrite-xml") testImplementation("org.openrewrite:rewrite-kotlin") testImplementation("org.openrewrite.gradle.tooling:model:${rewriteVersion}") testRuntimeOnly(gradleApi()) diff --git a/src/main/java/org/openrewrite/java/testing/pmd/PmdXml6to7Migration.java b/src/main/java/org/openrewrite/java/testing/pmd/PmdXml6to7Migration.java new file mode 100644 index 000000000..a7d30d072 --- /dev/null +++ b/src/main/java/org/openrewrite/java/testing/pmd/PmdXml6to7Migration.java @@ -0,0 +1,239 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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.java.testing.pmd; + +import org.jspecify.annotations.Nullable; +import org.openrewrite.ExecutionContext; +import org.openrewrite.Recipe; +import org.openrewrite.TreeVisitor; +import org.openrewrite.xml.XmlIsoVisitor; +import org.openrewrite.xml.tree.Content; +import org.openrewrite.xml.tree.Xml; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class PmdXml6to7Migration extends Recipe { + + private static final String PREFIX = "category/java/"; + + /** + * Java rules removed in PMD 7 that have a single unambiguous successor, mapped to that successor. + * Note that a rule's replacement often lives in a different category than the rule it replaces. + * + * @see PMD 7.0.0 release notes, "Removed Rules" + */ + private static final Map REPLACED_RULES = new LinkedHashMap<>(); + + /** + * Java rules removed in PMD 7 with no successor. PMD 7 fails on a `ref` it cannot resolve, so these are + * dropped from the ruleset rather than left behind. + */ + private static final Set REMOVED_RULES = new HashSet<>(Arrays.asList( + PREFIX + "codestyle.xml/AvoidFinalLocalVariable", + PREFIX + "performance.xml/AvoidUsingShortType", + PREFIX + "errorprone.xml/CloneThrowsCloneNotSupportedException", + PREFIX + "performance.xml/SimplifyStartsWith")); + + static { + replaced("codestyle.xml/AbstractNaming", "codestyle.xml/ClassNamingConventions"); + replaced("codestyle.xml/AvoidPrefixingMethodParameters", "codestyle.xml/FormalParameterNamingConventions"); + replaced("errorprone.xml/BadComparison", "errorprone.xml/ComparisonWithNaN"); + replaced("errorprone.xml/BeanMembersShouldSerialize", "errorprone.xml/NonSerializableClass"); + replaced("errorprone.xml/DataflowAnomalyAnalysis", "bestpractices.xml/UnusedAssignment"); + replaced("codestyle.xml/DefaultPackage", "codestyle.xml/CommentDefaultAccessModifier"); + replaced("errorprone.xml/DoNotCallSystemExit", "errorprone.xml/DoNotTerminateVM"); + replaced("codestyle.xml/DontImportJavaLang", "codestyle.xml/UnnecessaryImport"); + replaced("errorprone.xml/DontImportSun", "errorprone.xml/UnsupportedJdkApiUsage"); + replaced("codestyle.xml/DuplicateImports", "codestyle.xml/UnnecessaryImport"); + replaced("errorprone.xml/EmptyFinallyBlock", "codestyle.xml/EmptyControlStatement"); + replaced("errorprone.xml/EmptyIfStmt", "codestyle.xml/EmptyControlStatement"); + replaced("errorprone.xml/EmptyInitializer", "codestyle.xml/EmptyControlStatement"); + replaced("errorprone.xml/EmptyStatementBlock", "codestyle.xml/EmptyControlStatement"); + replaced("errorprone.xml/EmptyStatementNotInLoop", "codestyle.xml/UnnecessarySemicolon"); + replaced("errorprone.xml/EmptySwitchStatements", "codestyle.xml/EmptyControlStatement"); + replaced("errorprone.xml/EmptySynchronizedBlock", "codestyle.xml/EmptyControlStatement"); + replaced("errorprone.xml/EmptyTryBlock", "codestyle.xml/EmptyControlStatement"); + replaced("errorprone.xml/EmptyWhileStmt", "codestyle.xml/EmptyControlStatement"); + replaced("design.xml/ExcessiveClassLength", "design.xml/NcssCount"); + replaced("design.xml/ExcessiveMethodLength", "design.xml/NcssCount"); + replaced("codestyle.xml/ForLoopsMustUseBraces", "codestyle.xml/ControlStatementBraces"); + replaced("codestyle.xml/IfElseStmtsMustUseBraces", "codestyle.xml/ControlStatementBraces"); + replaced("codestyle.xml/IfStmtsMustUseBraces", "codestyle.xml/ControlStatementBraces"); + replaced("errorprone.xml/ImportFromSamePackage", "codestyle.xml/UnnecessaryImport"); + replaced("errorprone.xml/InvalidSlf4jMessageFormat", "errorprone.xml/InvalidLogMessageFormat"); + replaced("errorprone.xml/LoggerIsNotStaticFinal", "errorprone.xml/ProperLogger"); + replaced("errorprone.xml/MissingBreakInSwitch", "errorprone.xml/ImplicitSwitchFallThrough"); + replaced("design.xml/ModifiedCyclomaticComplexity", "design.xml/CyclomaticComplexity"); + replaced("design.xml/NcssConstructorCount", "design.xml/NcssCount"); + replaced("design.xml/NcssMethodCount", "design.xml/NcssCount"); + replaced("design.xml/NcssTypeCount", "design.xml/NcssCount"); + replaced("bestpractices.xml/PositionLiteralsFirstInCaseInsensitiveComparisons", "bestpractices.xml/LiteralsFirstInComparisons"); + replaced("bestpractices.xml/PositionLiteralsFirstInComparisons", "bestpractices.xml/LiteralsFirstInComparisons"); + replaced("errorprone.xml/ReturnEmptyArrayRatherThanNull", "errorprone.xml/ReturnEmptyCollectionRatherThanNull"); + replaced("design.xml/SimplifyBooleanAssertion", "bestpractices.xml/SimplifiableTestAssertion"); + replaced("design.xml/StdCyclomaticComplexity", "design.xml/CyclomaticComplexity"); + replaced("codestyle.xml/SuspiciousConstantFieldName", "codestyle.xml/FieldNamingConventions"); + replaced("performance.xml/UnnecessaryWrapperObjectCreation", "codestyle.xml/UnnecessaryBoxing"); + replaced("multithreading.xml/UnsynchronizedStaticDateFormatter", "multithreading.xml/UnsynchronizedStaticFormatter"); + replaced("bestpractices.xml/UnusedImports", "codestyle.xml/UnnecessaryImport"); + replaced("bestpractices.xml/UseAssertEqualsInsteadOfAssertTrue", "bestpractices.xml/SimplifiableTestAssertion"); + replaced("bestpractices.xml/UseAssertNullInsteadOfAssertEquals", "bestpractices.xml/SimplifiableTestAssertion"); + replaced("bestpractices.xml/UseAssertSameInsteadOfAssertEquals", "bestpractices.xml/SimplifiableTestAssertion"); + replaced("bestpractices.xml/UseAssertTrueInsteadOfAssertEquals", "bestpractices.xml/SimplifiableTestAssertion"); + replaced("codestyle.xml/WhileLoopsMustUseBraces", "codestyle.xml/ControlStatementBraces"); + + // Deliberately not mapped, because PMD 7 splits each of these across several rules and picking one + // for the reader would silently change what the ruleset covers. They are left in place for a human: + // performance.xml/{Boolean,Byte,Integer,Long,Short}Instantiation + // -> codestyle.xml/UnnecessaryBoxing and bestpractices.xml/PrimitiveWrapperInstantiation + // codestyle.xml/MIsLeadingVariableName, codestyle.xml/VariableNamingConventions + // -> codestyle.xml/{Field,FormalParameter,LocalVariable}NamingConventions + } + + private static void replaced(String pmd6Rule, String pmd7Rule) { + REPLACED_RULES.put(PREFIX + pmd6Rule, PREFIX + pmd7Rule); + } + + @Override + public String getDisplayName() { + return "Migrate PMD 6 rulesets to PMD 7"; + } + + @Override + public String getDescription() { + return "Update references to Java rules that were removed in PMD 7 in a PMD `ruleset` XML file. Rules " + + "with a single PMD 7 successor are repointed at it, rules removed without a successor are dropped, " + + "and a successor that the ruleset already selects is not added twice. Rules that PMD 7 splits " + + "across several successors are left alone, since choosing one would change what the ruleset covers."; + } + + @Override + public TreeVisitor getVisitor() { + return new XmlIsoVisitor() { + @Override + public Xml.Document visitDocument(Xml.Document document, ExecutionContext ctx) { + // A PMD ruleset is always a `ruleset` document; skip any other XML rather than matching a + // `ruleset` tag that happens to appear somewhere else. + if (!"ruleset".equals(document.getRoot().getName())) { + return document; + } + return super.visitDocument(document, ctx); + } + + @Override + public Xml.Tag visitTag(Xml.Tag tag, ExecutionContext ctx) { + Xml.Tag t = super.visitTag(tag, ctx); + if (!"ruleset".equals(t.getName()) || t.getContent() == null) { + return t; + } + + List content = new ArrayList<>(t.getContent()); + boolean changed = false; + for (int i = content.size() - 1; i >= 0; i--) { + Xml.Tag rule = asRule(content.get(i)); + String ref = rule == null ? null : ref(rule); + if (ref == null) { + continue; + } + if (REMOVED_RULES.contains(ref)) { + content.remove(i); + changed = true; + continue; + } + String replacement = REPLACED_RULES.get(ref); + if (replacement == null) { + continue; + } + content.remove(i); + changed = true; + if (!selects(content, replacement)) { + insertSorted(content, withRef(rule, replacement)); + } + } + return changed ? t.withContent(content) : t; + } + + /** + * Insert a rule at the position that keeps it in `ref` order relative to the rules already present, + * taking the indentation of whichever rule it is placed next to. Rules that are not being replaced + * are never reordered, so a ruleset that happens to be unsorted stays as its author wrote it. + */ + private void insertSorted(List content, Xml.Tag rule) { + int lastRule = -1; + for (int i = 0; i < content.size(); i++) { + Xml.Tag existing = asRule(content.get(i)); + if (existing == null) { + continue; + } + String existingRef = ref(existing); + if (existingRef != null && existingRef.compareTo(ref(rule)) > 0) { + content.add(i, rule.withPrefix(existing.getPrefix())); + return; + } + lastRule = i; + } + if (lastRule == -1) { + content.add(rule); + } else { + content.add(lastRule + 1, rule.withPrefix(content.get(lastRule).getPrefix())); + } + } + + private boolean selects(List content, String ref) { + for (Content c : content) { + Xml.Tag rule = asRule(c); + if (rule != null && ref.equals(ref(rule))) { + return true; + } + } + return false; + } + + private Xml.@Nullable Tag asRule(Content content) { + if (content instanceof Xml.Tag && "rule".equals(((Xml.Tag) content).getName())) { + return (Xml.Tag) content; + } + return null; + } + + private @Nullable String ref(Xml.Tag rule) { + for (Xml.Attribute attribute : rule.getAttributes()) { + if ("ref".equals(attribute.getKeyAsString())) { + return attribute.getValueAsString(); + } + } + return null; + } + + private Xml.Tag withRef(Xml.Tag rule, String ref) { + List attributes = new ArrayList<>(rule.getAttributes()); + for (int i = 0; i < attributes.size(); i++) { + Xml.Attribute attribute = attributes.get(i); + if ("ref".equals(attribute.getKeyAsString())) { + attributes.set(i, attribute.withValue(attribute.getValue().withValue(ref))); + } + } + return rule.withAttributes(attributes); + } + }; + } +} diff --git a/src/main/resources/META-INF/rewrite/recipes.csv b/src/main/resources/META-INF/rewrite/recipes.csv index a22bab8aa..75b006525 100644 --- a/src/main/resources/META-INF/rewrite/recipes.csv +++ b/src/main/resources/META-INF/rewrite/recipes.csv @@ -247,6 +247,7 @@ maven,org.openrewrite.recipe:rewrite-testing-frameworks,org.openrewrite.java.tes maven,org.openrewrite.recipe:rewrite-testing-frameworks,org.openrewrite.java.testing.mockito.SimplifyMockitoVerifyWhenGiven,"Call to Mockito method ""verify"", ""when"" or ""given"" should be simplified","Fixes Sonar issue `java:S6068`: Call to Mockito method ""verify"", ""when"" or ""given"" should be simplified.",1,Mockito,Testing,Java,,,Basic building blocks for transforming Java code.,, maven,org.openrewrite.recipe:rewrite-testing-frameworks,org.openrewrite.java.testing.mockito.ThenThrowCheckedExceptionToRuntimeException,Replace undeclared checked exceptions in `thenThrow` with `RuntimeException`,"In Mockito 3+, `thenThrow()` validates that checked exceptions are declared in the mocked method's `throws` clause. This recipe replaces checked exception class literals in `thenThrow()` calls with `RuntimeException.class` when the mocked method does not declare the exception.",1,Mockito,Testing,Java,,,Basic building blocks for transforming Java code.,, maven,org.openrewrite.recipe:rewrite-testing-frameworks,org.openrewrite.java.testing.mockito.VerifyZeroToNoMoreInteractions,Replace `verifyZeroInteractions()` with `verifyNoMoreInteractions()`,Replaces `verifyZeroInteractions()` with `verifyNoMoreInteractions()` in Mockito tests when migration when using a Mockito version < 3.x.,1,Mockito,Testing,Java,,,Basic building blocks for transforming Java code.,, +maven,org.openrewrite.recipe:rewrite-testing-frameworks,org.openrewrite.java.testing.pmd.PmdXml6to7Migration,Migrate PMD 6 rulesets to PMD 7,"Update references to Java rules that were removed in PMD 7 in a PMD `ruleset` XML file. Rules with a single PMD 7 successor are repointed at it, rules removed without a successor are dropped, and a successor that the ruleset already selects is not added twice. Rules that PMD 7 splits across several successors are left alone, since choosing one would change what the ruleset covers.",1,Pmd,Testing,Java,,,Basic building blocks for transforming Java code.,, maven,org.openrewrite.recipe:rewrite-testing-frameworks,org.openrewrite.java.testing.testcontainers.AddTestcontainersAnnotations,Adopt `@Container` and add `@Testcontainers`,Convert Testcontainers `@Rule`/`@ClassRule` to JUnit 5 `@Container` and add `@Testcontainers`.,1,Testcontainers,Testing,Java,Recipes for [Testcontainers](https://testcontainers.com/) integration testing with Docker.,,Basic building blocks for transforming Java code.,, maven,org.openrewrite.recipe:rewrite-testing-frameworks,org.openrewrite.java.testing.testcontainers.ConvertToRawType,Remove parameterized type arguments from a Java class,Convert parameterized types of a specified Java class to their raw types.,1,Testcontainers,Testing,Java,Recipes for [Testcontainers](https://testcontainers.com/) integration testing with Docker.,,Basic building blocks for transforming Java code.,"[{""name"":""fullyQualifiedTypeName"",""type"":""String"",""displayName"":""Fully qualified type name"",""description"":""The fully qualified name of the Java class to convert to its raw type."",""example"":""org.testcontainers.containers.PostgreSQLContainer"",""required"":true}]", maven,org.openrewrite.recipe:rewrite-testing-frameworks,org.openrewrite.java.testing.testcontainers.ExplicitContainerImage,Add image argument to container constructor,"Set the image to use for a container explicitly if unset, rather than relying on the default image for the container class.",1,Testcontainers,Testing,Java,Recipes for [Testcontainers](https://testcontainers.com/) integration testing with Docker.,,Basic building blocks for transforming Java code.,"[{""name"":""containerClass"",""type"":""String"",""displayName"":""Container class"",""description"":""The fully qualified name of the container class to use."",""example"":""org.testcontainers.containers.NginxContainer"",""required"":true},{""name"":""image"",""type"":""String"",""displayName"":""Image to use"",""description"":""The image to use for the container."",""example"":""nginx:1.9.4"",""required"":true},{""name"":""parseImage"",""type"":""Boolean"",""displayName"":""Parse image"",""description"":""Whether to call `DockerImageName.parse(image)`.""}]", diff --git a/src/test/java/org/openrewrite/java/testing/pmd/PmdXml6to7MigrationTest.java b/src/test/java/org/openrewrite/java/testing/pmd/PmdXml6to7MigrationTest.java new file mode 100644 index 000000000..788b17bab --- /dev/null +++ b/src/test/java/org/openrewrite/java/testing/pmd/PmdXml6to7MigrationTest.java @@ -0,0 +1,256 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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.java.testing.pmd; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.InMemoryExecutionContext; +import org.openrewrite.java.JavaParser; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.xml.Assertions.xml; + +public class PmdXml6to7MigrationTest + implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec + .parser(JavaParser.fromJavaVersion() + .classpathFromResources(new InMemoryExecutionContext())) + .recipe(new PmdXml6to7Migration()); + } + + @DocumentExample + @Test + void shouldMigrateXml6toXml7() { + //language=java + rewriteRun( + xml( +""" + + + + This is a description + + + +""", + """ + + + + This is a description + + + + """ + )); + } + + @Test + void noChanges_on_empty_ruleset() { + //language=java + rewriteRun( + xml( + """ + + + This is a description + + """ + )); + } + + @Test + void preserve_custom_rule() { + //language=java + rewriteRun( + xml( + """ + + + This is a description + + + """ + )); + } + + @Test + void replacementMovesToAnotherCategory() { + rewriteRun( + xml( + """ + + + + + + """, + """ + + + + + + """ + )); + } + + @Test + void dropRuleRemovedWithoutReplacement() { + rewriteRun( + xml( + """ + + + + + + """, + """ + + + + + """ + )); + } + + @Test + void severalRemovedRulesShareOneReplacement() { + rewriteRun( + xml( + """ + + + + + + """, + """ + + + + + """ + )); + } + + @Test + void doNotAddReplacementTheRulesetAlreadySelects() { + rewriteRun( + xml( + """ + + + + + + """, + """ + + + + + """ + )); + } + + @Test + void retainNestedPropertiesOnReplacedRule() { + rewriteRun( + xml( + """ + + + + + + + + + """, + """ + + + + + + + + + """ + )); + } + + @Test + void leaveRulesPmd7SplitsAcrossSeveralSuccessors() { + rewriteRun( + xml( + """ + + + + + + """ + )); + } + + @Test + void leaveRulesetTagThatIsNotTheDocumentRoot() { + rewriteRun( + xml( + """ + + + + + + + """ + )); + } + + @Test + void rulesStillValidInPmd7AreLeftAlone() { + rewriteRun( + xml( + """ + + + + + + """ + )); + } +}