Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 <type> 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 `<dependencies>` and `<dependencyManagement>` sections of the `pom.xml`.";
String description = "Removes duplicated dependencies in the `<dependencies>` and `<dependencyManagement>` 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);

Expand All @@ -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<DependencyKey, Xml.Tag>());
visitedTag = removeDuplicates(visitedTag, false);
} else if (isManagedDependenciesTag()) {
getCursor().putMessage("managedDependencies", new HashMap<DependencyKey, Xml.Tag>());
} else if (isDependencyTag()) {
Map<DependencyKey, Xml.Tag> 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 <dependencies>} 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 <dependencyManagement>} 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<? extends Content> content = dependencies.getContent();
if (content == null) {
return dependencies;
}

List<Content> deduplicated = new ArrayList<>(content.size());
Map<DependencyKey, Integer> firstDeclarations = new HashMap<>();
Map<DependencyKey, Set<String>> managedFields = new HashMap<>();
Map<DependencyKey, Set<String>> 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<String> fields = declaredManagedFields(dependency);
Set<String> exclusions = declaredExclusions(dependency);
Set<String> earlierFields = managedFields.get(dependencyKey);
if (earlierFields == null) {
managedFields.put(dependencyKey, fields);
managedExclusions.put(dependencyKey, exclusions);
} else {
Set<String> 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<DependencyKey, Xml.Tag> 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<String> declaredManagedFields(Xml.Tag dependency) {
Set<String> 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<String> declaredExclusions(Xml.Tag dependency) {
Set<String> 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<String, List<String>> declaredFields(Xml.Tag dependency) {
Map<String, List<String>> 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
// `<dependencyManagement>`; `type` is defaulted anyway to keep collapsing a bare declaration onto
// one spelling out `<type>jar</type>`, as `removeDependencyWithDefaultType` expects.
fields.putIfAbsent("type", singletonList(DEFAULT_DEPENDENCY_TYPE));
return fields;
}

private String fieldValue(Xml.Tag field) {
List<? extends Content> 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() {
Expand Down Expand Up @@ -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;
}
});
}
Expand All @@ -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);
}
}
}
Loading