diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/RemoveDuplicateDependencies.java b/rewrite-maven/src/main/java/org/openrewrite/maven/RemoveDuplicateDependencies.java index fb0bc04e48..56eca94834 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/RemoveDuplicateDependencies.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/RemoveDuplicateDependencies.java @@ -28,18 +28,25 @@ import org.openrewrite.maven.tree.ResolvedManagedDependency; import org.openrewrite.maven.tree.Scope; import org.openrewrite.xml.XPathMatcher; +import org.openrewrite.xml.tree.Content; import org.openrewrite.xml.tree.Xml; import java.time.Duration; import java.util.*; +import static java.util.Collections.singletonList; + @Value @EqualsAndHashCode(callSuper = false) public class RemoveDuplicateDependencies extends Recipe { + // Maven's implicit when the tag is absent + private static final String DEFAULT_DEPENDENCY_TYPE = "jar"; + String displayName = "Remove duplicate Maven dependencies"; - String description = "Removes duplicated dependencies in the `` and `` sections of the `pom.xml`."; + String description = "Removes duplicated dependencies in the `` and `` sections of the `pom.xml`. " + + "The declaration Maven resolves to is the one kept, at the position of the first of the duplicates, so the effective dependency model is unchanged."; Duration estimatedEffortPerOccurrence = Duration.ofMinutes(2); @@ -58,42 +65,162 @@ public Xml.Document visitDocument(Xml.Document document, ExecutionContext ctx) { private final XPathMatcher DEPENDENCIES_MATCHER = new XPathMatcher("/project/dependencies"); private final XPathMatcher MANAGED_DEPENDENCIES_MATCHER = new XPathMatcher("/project/dependencyManagement/dependencies"); - @SuppressWarnings("DataFlowIssue") @Override - public Xml.@Nullable Tag visitTag(Xml.Tag tag, ExecutionContext ctx) { + public Xml.Tag visitTag(Xml.Tag tag, ExecutionContext ctx) { + Xml.Tag visitedTag = tag; if (isDependenciesTag()) { - getCursor().putMessage("dependencies", new HashMap()); + visitedTag = removeDuplicates(visitedTag, false); } else if (isManagedDependenciesTag()) { - getCursor().putMessage("managedDependencies", new HashMap()); - } else if (isDependencyTag()) { - Map dependencies = getCursor().getNearestMessage("dependencies"); - DependencyKey dependencyKey = getDependencyKey(tag); - if (dependencyKey != null) { - Xml.Tag existing = dependencies.putIfAbsent(dependencyKey, tag); - if (existing != null && existing != tag) { - maybeUpdateModel(); - return null; + visitedTag = removeDuplicates(visitedTag, true); + } + if (visitedTag != tag) { + maybeUpdateModel(); + } + return super.visitTag(visitedTag, ctx); + } + + /** + * Maven warns when a POM declares the same dependency twice + * ({@code 'dependencies.dependency.(groupId:artifactId:type:classifier)' must be unique}) but still + * builds an effective model, and the two sections resolve differently. In {@code } the + * last declaration wins, see {@code rootDependencies} in {@link org.openrewrite.maven.tree.ResolvedPom}, + * so a differing duplicate takes the place of the earlier one rather than being dropped. In + * {@code } entries merge field-wise from the first declaration that sets each + * field, with exclusions accumulating, so a later duplicate only goes when it adds neither; a repeated + * BOM import is a duplicate only at the same version, since the first import wins for the entries both + * manage. Either way the survivor keeps the first declaration's position, which is where the resolved + * model already put it. + */ + private Xml.Tag removeDuplicates(Xml.Tag dependencies, boolean managed) { + List content = dependencies.getContent(); + if (content == null) { + return dependencies; + } + + List deduplicated = new ArrayList<>(content.size()); + Map firstDeclarations = new HashMap<>(); + Map> managedFields = new HashMap<>(); + Map> managedExclusions = new HashMap<>(); + boolean removed = false; + for (Content child : content) { + if (child instanceof Xml.Tag && "dependency".equals(((Xml.Tag) child).getName())) { + Xml.Tag dependency = (Xml.Tag) child; + DependencyKey dependencyKey = managed ? getManagedDependencyKey(dependency) : getDependencyKey(dependency); + if (dependencyKey != null) { + if (managed) { + Set fields = declaredManagedFields(dependency); + Set exclusions = declaredExclusions(dependency); + Set earlierFields = managedFields.get(dependencyKey); + if (earlierFields == null) { + managedFields.put(dependencyKey, fields); + managedExclusions.put(dependencyKey, exclusions); + } else { + Set earlierExclusions = managedExclusions.get(dependencyKey); + if (earlierFields.containsAll(fields) && earlierExclusions.containsAll(exclusions)) { + removed = true; + continue; + } + // The duplicate contributes to the effective entry, so it has to stay + earlierFields.addAll(fields); + earlierExclusions.addAll(exclusions); + } + } else { + Integer firstDeclaration = firstDeclarations.putIfAbsent(dependencyKey, deduplicated.size()); + if (firstDeclaration != null) { + Xml.Tag effective = (Xml.Tag) deduplicated.get(firstDeclaration); + if (!isSameDeclaration(effective, dependency)) { + deduplicated.set(firstDeclaration, dependency.withPrefix(effective.getPrefix())); + } + removed = true; + continue; + } + } } } - } else if (isManagedDependencyTag()) { - Map dependencies = getCursor().getNearestMessage("managedDependencies"); - DependencyKey dependencyKey = getManagedDependencyKey(tag); - if (dependencyKey != null) { - // Additionally compare classifier and type, which are only partially compared in `findManagedDependency` - String classifier = getResolutionResult().getPom().getValue(tag.getChildValue("classifier").orElse(null)); - String type = getResolutionResult().getPom().getValue(tag.getChildValue("type").orElse("jar")); - if (Objects.equals(classifier, dependencyKey.getClassifier()) && - Objects.equals(type, dependencyKey.getType())) { - Xml.Tag existing = dependencies.putIfAbsent(dependencyKey, tag); - if (existing != null && existing != tag) { - maybeUpdateModel(); - return null; - } + deduplicated.add(child); + } + return removed ? dependencies.withContent(deduplicated) : dependencies; + } + + /** + * The names of the fields this declaration sets, {@code exclusions} excepted, which + * {@link #declaredExclusions} compares by value because Maven accumulates them rather than taking + * them from one declaration. Values are not compared, as the effective entry takes each field from + * the first declaration that sets it whatever a later one says. + */ + private Set declaredManagedFields(Xml.Tag dependency) { + Set fields = new HashSet<>(); + for (Xml.Tag field : dependency.getChildren()) { + if (!"exclusions".equals(field.getName()) && !fieldValue(field).isEmpty()) { + fields.add(field.getName()); + } + } + return fields; + } + + private Set declaredExclusions(Xml.Tag dependency) { + Set exclusions = new HashSet<>(); + for (Xml.Tag field : dependency.getChildren()) { + if ("exclusions".equals(field.getName())) { + for (Xml.Tag exclusion : field.getChildren()) { + exclusions.add(fieldValue(exclusion)); } + } + } + return exclusions; + } + /** + * Whether both entries resolve to the same declaration, ignoring formatting and comments and + * resolving property placeholders. Any difference not known to be irrelevant counts as one. + */ + private boolean isSameDeclaration(Xml.Tag dependency, Xml.Tag other) { + return declaredFields(dependency).equals(declaredFields(other)); + } + + private Map> declaredFields(Xml.Tag dependency) { + Map> fields = new HashMap<>(); + for (Xml.Tag field : dependency.getChildren()) { + fields.computeIfAbsent(field.getName(), name -> new ArrayList<>()).add(fieldValue(field)); + } + // An omitted field is not generally the same as one restating its default, as it can also come from + // ``; `type` is defaulted anyway to keep collapsing a bare declaration onto + // one spelling out `jar`, as `removeDependencyWithDefaultType` expects. + fields.putIfAbsent("type", singletonList(DEFAULT_DEPENDENCY_TYPE)); + return fields; + } + + private String fieldValue(Xml.Tag field) { + List content = field.getContent(); + if (content == null) { + return ""; + } + StringBuilder value = new StringBuilder(); + boolean plainText = true; + for (Content child : content) { + if (child instanceof Xml.CharData) { + // Not `Xml.Tag#getValue`, which gives up on a value interrupted by a comment and would + // then make two different values look identical + value.append(((Xml.CharData) child).getText().trim()); + } else if (child instanceof Xml.Comment) { + // Not part of the value Maven reads + } else if (child instanceof Xml.Tag) { + Xml.Tag nested = (Xml.Tag) child; + // Serializes nested tags into a key that is only ever compared for equality, never parsed + value.append(nested.getName()).append('=').append(fieldValue(nested)).append(';'); + plainText = false; + } else { + // Content that cannot be compared as text falls back to its identity, so two values are + // never equal on the strength of a part that was not actually compared + value.append(child.getId()); + plainText = false; } } - return super.visitTag(tag, ctx); + if (!plainText) { + return value.toString(); + } + String resolved = getResolutionResult().getPom().getValue(value.toString()); + return resolved != null ? resolved : value.toString(); } private boolean isDependenciesTag() { @@ -123,11 +250,29 @@ private boolean isManagedDependenciesTag() { } private @Nullable DependencyKey getManagedDependencyKey(Xml.Tag tag) { + String classifier = getResolutionResult().getPom().getValue(tag.getChildValue("classifier").orElse(null)); + String type = getResolutionResult().getPom().getValue(tag.getChildValue("type").orElse(DEFAULT_DEPENDENCY_TYPE)); if (tag.getChildValue("scope").filter("import"::equalsIgnoreCase).isPresent()) { - return DependencyKey.from(tag); + String artifactId = getResolutionResult().getPom().getValue(tag.getChildValue("artifactId").orElse(null)); + if (artifactId == null) { + return null; + } + return new DependencyKey( + getResolutionResult().getPom().getValue(tag.getChildValue("groupId").orElse(null)), + artifactId, + type, + classifier, + Scope.Import, + tag.getChild("version").map(this::fieldValue).orElse(null)); } ResolvedManagedDependency resolvedDependency = findManagedDependency(tag); - return resolvedDependency != null ? DependencyKey.from(resolvedDependency) : null; + if (resolvedDependency == null) { + return null; + } + DependencyKey dependencyKey = DependencyKey.from(resolvedDependency); + // Additionally compare classifier and type, which are only partially compared in `findManagedDependency` + return Objects.equals(classifier, dependencyKey.getClassifier()) && + Objects.equals(type, dependencyKey.getType()) ? dependencyKey : null; } }); } @@ -145,23 +290,19 @@ private static class DependencyKey { Scope scope; + /** + * Only set for BOM imports: the first import wins for the entries both versions manage, but a + * different version may manage entries the first one does not. + */ + @Nullable + String version; + public static DependencyKey from(ResolvedDependency dependency, Scope scope) { - return new DependencyKey(dependency.getGroupId(), dependency.getArtifactId(), dependency.getType(), dependency.getClassifier(), scope); + return new DependencyKey(dependency.getGroupId(), dependency.getArtifactId(), dependency.getType(), dependency.getClassifier(), scope, null); } public static DependencyKey from(ResolvedManagedDependency dependency) { - return new DependencyKey(dependency.getGroupId(), dependency.getArtifactId(), dependency.getType(), dependency.getClassifier(), Scope.Compile); - } - - public static @Nullable DependencyKey from(Xml.Tag tag) { - return tag.getChildValue("artifactId").map(artifactId -> - new DependencyKey( - tag.getChildValue("groupId").orElse(null), - artifactId, - tag.getChildValue("type").orElse("jar"), - tag.getChildValue("classifier").orElse(null), - tag.getChildValue("scope").map(Scope::fromName).orElse(Scope.Compile) - )).orElse(null); + return new DependencyKey(dependency.getGroupId(), dependency.getArtifactId(), dependency.getType(), dependency.getClassifier(), Scope.Compile, null); } } } diff --git a/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv b/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv index de6d9247c4..e9c90ba6f0 100644 --- a/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv +++ b/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv @@ -37,7 +37,7 @@ maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.ModernizeObsoletePoms, maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.OrderPomElements,Order POM elements,Order POM elements according to the [recommended](https://maven.apache.org/developers/conventions/code.html#pom-code-convention) order.,1,,Maven,,Recipes to search and transform [Apache Maven](https://maven.apache.org/) POMs.,, maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.RemoveBomManagedDirectDependencies,Remove direct dependencies that are managed by a BOM with incompatible versions,"Removes directly declared dependencies when they have a version that is incompatible with the version managed by an imported BOM. This is useful during framework upgrades (e.g., Spring Boot) where transitive dependencies receive major version bumps and explicitly declared older versions should be removed to use the BOM-managed versions instead. A dependency is only removed when it would still be reachable transitively through another direct dependency, so the BOM-managed version takes its place rather than the dependency disappearing from the classpath.",1,,Maven,,Recipes to search and transform [Apache Maven](https://maven.apache.org/) POMs.,"[{""name"":""bomGroupPattern"",""type"":""String"",""displayName"":""BOM group pattern"",""description"":""Group ID glob pattern for BOMs to consider. For example, `org.springframework.boot` to match Spring Boot BOMs."",""example"":""org.springframework.boot"",""required"":true},{""name"":""bomArtifactPattern"",""type"":""String"",""displayName"":""BOM artifact pattern"",""description"":""Artifact ID glob pattern for BOMs to consider. For example, `*-dependencies` to match Spring Boot's BOM."",""example"":""*-dependencies""},{""name"":""dependencyGroupPattern"",""type"":""String"",""displayName"":""Dependency group pattern"",""description"":""Group ID glob pattern for dependencies to check against BOM. Use `*` to match all dependencies."",""example"":""*""},{""name"":""dependencyArtifactPattern"",""type"":""String"",""displayName"":""Dependency artifact pattern"",""description"":""Artifact ID glob pattern for dependencies to check against BOM. Use `*` to match all dependencies."",""example"":""*""}]", maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.RemoveDependency,Remove Maven dependency,"Removes a single dependency from the section of the pom.xml. Does not remove usage of the dependency classes, nor guard against the resulting compilation errors.",1,,Maven,,Recipes to search and transform [Apache Maven](https://maven.apache.org/) POMs.,"[{""name"":""groupId"",""type"":""String"",""displayName"":""Group"",""description"":""The first part of a dependency coordinate `com.google.guava:guava:VERSION`."",""example"":""com.google.guava"",""required"":true},{""name"":""artifactId"",""type"":""String"",""displayName"":""Artifact"",""description"":""The second part of a dependency coordinate `com.google.guava:guava:VERSION`."",""example"":""guava"",""required"":true},{""name"":""scope"",""type"":""String"",""displayName"":""Scope"",""description"":""Only remove dependencies if they are in this scope. If 'runtime', this willalso remove dependencies in the 'compile' scope because 'compile' dependencies are part of the runtime dependency set"",""example"":""compile"",""valid"":[""compile"",""test"",""runtime"",""provided""]}]", -maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.RemoveDuplicateDependencies,Remove duplicate Maven dependencies,Removes duplicated dependencies in the `` and `` sections of the `pom.xml`.,1,,Maven,,Recipes to search and transform [Apache Maven](https://maven.apache.org/) POMs.,, +maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.RemoveDuplicateDependencies,Remove duplicate Maven dependencies,"Removes duplicated dependencies in the `` and `` sections of the `pom.xml`. The declaration Maven resolves to is the one kept, at the position of the first of the duplicates, so the effective dependency model is unchanged.",1,,Maven,,Recipes to search and transform [Apache Maven](https://maven.apache.org/) POMs.,, maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.RemoveDuplicatePluginDeclarations,Remove duplicate plugin declarations,"Maven 4 rejects duplicate plugin declarations (same groupId and artifactId) with an error. This recipe removes duplicate plugin declarations, keeping only the first occurrence.",1,,Maven,,Recipes to search and transform [Apache Maven](https://maven.apache.org/) POMs.,, maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.RemoveExclusion,Remove exclusion,Remove any matching exclusion from any matching dependency.,1,,Maven,,Recipes to search and transform [Apache Maven](https://maven.apache.org/) POMs.,"[{""name"":""groupId"",""type"":""String"",""displayName"":""Group"",""description"":""The first part of a dependency coordinate `com.google.guava:guava:VERSION`. Supports glob."",""example"":""com.google.guava"",""required"":true},{""name"":""artifactId"",""type"":""String"",""displayName"":""Artifact"",""description"":""The second part of a dependency coordinate `com.google.guava:guava:VERSION`. Supports glob."",""example"":""guava"",""required"":true},{""name"":""exclusionGroupId"",""type"":""String"",""displayName"":""Exclusion group"",""description"":""The first part of a dependency coordinate `com.google.guava:guava:VERSION`. Supports glob."",""example"":""com.google.guava"",""required"":true},{""name"":""exclusionArtifactId"",""type"":""String"",""displayName"":""Exclusion artifact"",""description"":""The second part of a dependency coordinate `com.google.guava:guava:VERSION`. Supports glob."",""example"":""guava"",""required"":true},{""name"":""onlyIneffective"",""type"":""Boolean"",""displayName"":""Only ineffective"",""description"":""Default false. If enabled, matching exclusions will only be removed if they are ineffective (if the excluded dependency was not actually a transitive dependency of the target dependency).""}]", maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.RemoveManagedDependency,Remove Maven managed dependency,Removes a single managed dependency from the section of the pom.xml.,1,,Maven,,Recipes to search and transform [Apache Maven](https://maven.apache.org/) POMs.,"[{""name"":""groupId"",""type"":""String"",""displayName"":""Group"",""description"":""The first part of a managed dependency coordinate `com.google.guava:guava:VERSION`."",""example"":""com.google.guava"",""required"":true},{""name"":""artifactId"",""type"":""String"",""displayName"":""Artifact"",""description"":""The second part of a managed dependency coordinate `com.google.guava:guava:VERSION`."",""example"":""guava"",""required"":true},{""name"":""scope"",""type"":""String"",""displayName"":""Scope"",""description"":""Only remove managed dependencies if they are in this scope. If `runtime`, this will also remove managed dependencies in the 'compile' scope because `compile` dependencies are part of the runtime dependency set."",""example"":""compile"",""valid"":[""compile"",""test"",""runtime"",""provided""]}]", diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/RemoveDuplicateDependenciesTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/RemoveDuplicateDependenciesTest.java index cb019d1d71..5adf7bc0a8 100644 --- a/rewrite-maven/src/test/java/org/openrewrite/maven/RemoveDuplicateDependenciesTest.java +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/RemoveDuplicateDependenciesTest.java @@ -273,7 +273,7 @@ void removeDependencyWithDifferentVersion() { com.google.inject guice - 4.2.1 + 4.2.2 @@ -586,4 +586,754 @@ void retainWithAndWithoutClassifier() { ) ); } + + @Test + void retainLaterDependencyDeclaration() { + rewriteRun( + pomXml( + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + + junit + junit + 4.13.1 + + + junit + junit + 4.13.2 + + + + com.google.guava + guava + 29.0-jre + false + + + com.google.guava + guava + 29.0-jre + true + + + + org.apache.httpcomponents + httpclient + 4.5.13 + + + org.apache.httpcomponents + httpclient + 4.5.13 + + + commons-codec + commons-codec + + + + + + """, + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + + junit + junit + 4.13.2 + + + + com.google.guava + guava + 29.0-jre + true + + + + org.apache.httpcomponents + httpclient + 4.5.13 + + + commons-codec + commons-codec + + + + + + """ + ) + ); + } + + @Test + void retainLaterDependencyDeclarationInReverseOrder() { + rewriteRun( + pomXml( + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + + junit + junit + 4.13.2 + + + junit + junit + 4.13.1 + + + + org.apache.httpcomponents + httpclient + 4.5.13 + + + commons-codec + commons-codec + + + + + org.apache.httpcomponents + httpclient + 4.5.13 + + + + """, + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + + junit + junit + 4.13.1 + + + + org.apache.httpcomponents + httpclient + 4.5.13 + + + + """ + ) + ); + } + + /** + * Managed duplicates merge field-wise from the first declaration that sets each field, so a later duplicate + * setting only the same fields contributes nothing, whatever its values. + */ + @Test + void retainFirstManagedDependencyDeclaration() { + rewriteRun( + pomXml( + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + + + com.acme + versioned + 1 + + + com.acme + versioned + 2 + + + + com.acme + scoped + 1 + compile + + + com.acme + scoped + 1 + runtime + + + + + """, + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + + + com.acme + versioned + 1 + + + + com.acme + scoped + 1 + compile + + + + + """ + ) + ); + } + + /** + * The effective managed entry can be made up of several declarations: Maven takes the version from the + * second because the first sets none, so removing it would leave no managed version at all. + */ + @Test + void retainManagedDuplicateSettingAFieldTheFirstLeavesUnset() { + rewriteRun( + pomXml( + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + + + com.google.guava + guava + + + com.google.guava + guava + 32.1.3-jre + + + + + """ + ) + ); + } + + /** + * Scope comes from the first declaration that sets it and exclusions accumulate, so those duplicates stay. + * Maven 3.9 never injects {@code } from {@code }, so a duplicate adding only + * it changes nothing either way and is kept rather than reasoned about. + */ + @Test + void retainManagedDuplicateContributingScopeOptionalOrExclusions() { + rewriteRun( + pomXml( + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + + + com.acme + scoped + 1 + + + com.acme + scoped + 1 + test + + + + com.acme + optional + 1 + + + com.acme + optional + 1 + true + + + + com.acme + excluded + 1 + + + com.acme + excluded + 1 + + + commons-logging + commons-logging + + + + + + + """ + ) + ); + } + + /** + * A later duplicate adding neither an unset field nor a new exclusion contributes nothing, so it still goes. + */ + @Test + void removeRedundantManagedDependencyDuplicates() { + rewriteRun( + pomXml( + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + + + com.acme + scoped + 1 + runtime + + + com.acme + scoped + 2 + + + + com.acme + excluded + 1 + + + commons-logging + commons-logging + + + + + com.acme + excluded + 1 + + + commons-logging + commons-logging + + + + + + + """, + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + + + com.acme + scoped + 1 + runtime + + + + com.acme + excluded + 1 + + + commons-logging + commons-logging + + + + + + + """ + ) + ); + } + + /** + * The first import wins for the entries both versions manage, but the second may manage entries the first + * does not. Only a repeat of the same version changes nothing, see `removeDuplicatedDependencyWithImportScope`. + */ + @Test + void retainRepeatedBomImportWithDifferentVersion() { + rewriteRun( + pomXml( + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + + + org.apache.logging.log4j + log4j-bom + 2.24.0 + import + pom + + + org.apache.logging.log4j + log4j-bom + 2.24.1 + import + pom + + + + + """ + ) + ); + } + + @Test + void removeRepeatedBomImportDeclaredThroughProperty() { + rewriteRun( + pomXml( + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + org.apache.logging.log4j + + + + + + ${log4j.groupId} + log4j-bom + 2.24.0 + import + pom + + + org.apache.logging.log4j + log4j-bom + 2.24.0 + import + pom + + + + + """, + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + org.apache.logging.log4j + + + + + + ${log4j.groupId} + log4j-bom + 2.24.0 + import + pom + + + + + """ + ) + ); + } + + /** + * The resolved model orders duplicates by their first declaration, so the survivor stays in that position + * rather than moving to where it was written. Comments keep their own, as in `preservesComments`. + */ + @Test + void retainLaterDeclarationInTheFirstPosition() { + rewriteRun( + pomXml( + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + + + junit + junit + 4.13.1 + + + + junit + junit + 4.13.2 + + + + """, + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + + + junit + junit + 4.13.2 + + + + + """ + ) + ); + } + + @Test + void removeDuplicateDeclaredThroughProperty() { + rewriteRun( + pomXml( + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + 29.0-jre + + + + + com.google.guava + guava + ${guava.version} + + + com.google.guava + guava + 29.0-jre + + + + """, + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + 29.0-jre + + + + + com.google.guava + guava + ${guava.version} + + + + """ + ) + ); + } + + /** + * The survivor is taken over whole, so only the `` tag's own indentation comes from the + * declaration it replaces. Duplicates written at different levels need a formatting recipe afterwards. + */ + @Test + void retainLaterDeclarationIndentedDifferently() { + rewriteRun( + pomXml( + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + + com.google.inject + guice + 4.2.1 + + + com.google.inject + guice + 4.2.2 + + + + """, + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + + com.google.inject + guice + 4.2.2 + + + + """ + ) + ); + } + + @Test + void retainLaterDeclarationWhenAValueContainsAComment() { + rewriteRun( + pomXml( + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + + com.google.guava + guava + 29.0-jre + false + + + com.google.guava + guava + 29.0-jre + true + + + + """, + """ + + 4.0.0 + + com.mycompany.app + my-app + 1 + + + + com.google.guava + guava + 29.0-jre + true + + + + """ + ) + ); + } }