View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.cling.invoker.mvnup.goals;
20  
21  import java.nio.file.Path;
22  import java.util.HashMap;
23  import java.util.HashSet;
24  import java.util.List;
25  import java.util.Map;
26  import java.util.Objects;
27  import java.util.Set;
28  import java.util.stream.Stream;
29  
30  import eu.maveniverse.domtrip.Document;
31  import eu.maveniverse.domtrip.Element;
32  import eu.maveniverse.domtrip.maven.Coordinates;
33  import eu.maveniverse.domtrip.maven.MavenPomElements;
34  import org.apache.maven.api.cli.mvnup.UpgradeOptions;
35  import org.apache.maven.api.di.Named;
36  import org.apache.maven.api.di.Priority;
37  import org.apache.maven.api.di.Singleton;
38  import org.apache.maven.cling.invoker.mvnup.UpgradeContext;
39  
40  import static eu.maveniverse.domtrip.maven.MavenPomElements.Attributes.COMBINE_APPEND;
41  import static eu.maveniverse.domtrip.maven.MavenPomElements.Attributes.COMBINE_CHILDREN;
42  import static eu.maveniverse.domtrip.maven.MavenPomElements.Attributes.COMBINE_MERGE;
43  import static eu.maveniverse.domtrip.maven.MavenPomElements.Attributes.COMBINE_OVERRIDE;
44  import static eu.maveniverse.domtrip.maven.MavenPomElements.Attributes.COMBINE_SELF;
45  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.BUILD;
46  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.DEPENDENCIES;
47  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.DEPENDENCY;
48  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.DEPENDENCY_MANAGEMENT;
49  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PARENT;
50  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PLUGIN;
51  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PLUGINS;
52  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PLUGIN_MANAGEMENT;
53  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PLUGIN_REPOSITORIES;
54  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PLUGIN_REPOSITORY;
55  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PROFILE;
56  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PROFILES;
57  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.RELATIVE_PATH;
58  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.REPOSITORIES;
59  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.REPOSITORY;
60  import static eu.maveniverse.domtrip.maven.MavenPomElements.Files.DEFAULT_PARENT_RELATIVE_PATH;
61  import static eu.maveniverse.domtrip.maven.MavenPomElements.Plugins.DEFAULT_MAVEN_PLUGIN_GROUP_ID;
62  import static eu.maveniverse.domtrip.maven.MavenPomElements.Plugins.MAVEN_PLUGIN_PREFIX;
63  
64  /**
65   * Strategy for applying Maven 4 compatibility fixes to POM files.
66   * Fixes issues that prevent POMs from being processed by Maven 4.
67   */
68  @Named
69  @Singleton
70  @Priority(20)
71  public class CompatibilityFixStrategy extends AbstractUpgradeStrategy {
72  
73      @Override
74      public boolean isApplicable(UpgradeContext context) {
75          UpgradeOptions options = getOptions(context);
76  
77          // Handle --all option (overrides individual options)
78          boolean useAll = options.all().orElse(false);
79          if (useAll) {
80              return true;
81          }
82  
83          // Apply default behavior: if no specific options are provided, enable --model
84          // OR if all options are explicitly disabled, still apply default behavior
85          boolean noOptionsSpecified = options.all().isEmpty()
86                  && options.infer().isEmpty()
87                  && options.model().isEmpty()
88                  && options.plugins().isEmpty()
89                  && options.modelVersion().isEmpty();
90  
91          boolean allOptionsDisabled = options.all().map(v -> !v).orElse(false)
92                  && options.infer().map(v -> !v).orElse(false)
93                  && options.model().map(v -> !v).orElse(false)
94                  && options.plugins().map(v -> !v).orElse(false)
95                  && options.modelVersion().isEmpty();
96  
97          if (noOptionsSpecified || allOptionsDisabled) {
98              return true;
99          }
100 
101         // Check if --model is explicitly set (and not part of "all disabled" scenario)
102         if (options.model().isPresent()) {
103             return options.model().get();
104         }
105 
106         return false;
107     }
108 
109     @Override
110     public String getDescription() {
111         return "Applying Maven 4 compatibility fixes";
112     }
113 
114     @Override
115     public UpgradeResult doApply(UpgradeContext context, Map<Path, Document> pomMap) {
116         Set<Path> processedPoms = new HashSet<>();
117         Set<Path> modifiedPoms = new HashSet<>();
118         Set<Path> errorPoms = new HashSet<>();
119 
120         for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
121             Path pomPath = entry.getKey();
122             Document pomDocument = entry.getValue();
123             processedPoms.add(pomPath);
124 
125             context.info(pomPath + " (checking for Maven 4 compatibility issues)");
126             context.indent();
127 
128             try {
129                 boolean hasIssues = false;
130 
131                 // Apply all compatibility fixes
132                 hasIssues |= fixUnsupportedCombineChildrenAttributes(pomDocument, context);
133                 hasIssues |= fixUnsupportedCombineSelfAttributes(pomDocument, context);
134                 hasIssues |= fixDuplicateDependencies(pomDocument, context);
135                 hasIssues |= fixDuplicatePlugins(pomDocument, context);
136                 hasIssues |= fixUnsupportedRepositoryExpressions(pomDocument, context);
137                 hasIssues |= fixIncorrectParentRelativePaths(pomDocument, pomPath, pomMap, context);
138 
139                 if (hasIssues) {
140                     context.success("Maven 4 compatibility issues fixed");
141                     modifiedPoms.add(pomPath);
142                 } else {
143                     context.success("No Maven 4 compatibility issues found");
144                 }
145             } catch (Exception e) {
146                 context.failure("Failed to fix Maven 4 compatibility issues" + ": " + e.getMessage());
147                 errorPoms.add(pomPath);
148             } finally {
149                 context.unindent();
150             }
151         }
152 
153         return new UpgradeResult(processedPoms, modifiedPoms, errorPoms);
154     }
155 
156     /**
157      * Fixes unsupported combine.children attribute values.
158      * Maven 4 only supports 'append' and 'merge', not 'override'.
159      */
160     private boolean fixUnsupportedCombineChildrenAttributes(Document pomDocument, UpgradeContext context) {
161         boolean fixed = false;
162         Element root = pomDocument.root();
163 
164         // Find all elements with combine.children="override" and change to "merge"
165         long fixedCombineChildrenCount = findElementsWithAttribute(root, COMBINE_CHILDREN, COMBINE_OVERRIDE)
166                 .peek(element -> {
167                     element.attributeObject(COMBINE_CHILDREN).value(COMBINE_MERGE);
168                     context.detail("Fixed: " + COMBINE_CHILDREN + "='" + COMBINE_OVERRIDE + "' → '" + COMBINE_MERGE
169                             + "' in " + element.name());
170                 })
171                 .count();
172         fixed |= fixedCombineChildrenCount > 0;
173 
174         return fixed;
175     }
176 
177     /**
178      * Fixes unsupported combine.self attribute values.
179      * Maven 4 only supports 'override', 'merge', and 'remove' (default is merge), not 'append'.
180      */
181     private boolean fixUnsupportedCombineSelfAttributes(Document pomDocument, UpgradeContext context) {
182         boolean fixed = false;
183         Element root = pomDocument.root();
184 
185         // Find all elements with combine.self="append" and change to "merge"
186         long fixedCombineSelfCount = findElementsWithAttribute(root, COMBINE_SELF, COMBINE_APPEND)
187                 .peek(element -> {
188                     element.attributeObject(COMBINE_SELF).value(COMBINE_MERGE);
189                     context.detail("Fixed: " + COMBINE_SELF + "='" + COMBINE_APPEND + "' → '" + COMBINE_MERGE + "' in "
190                             + element.name());
191                 })
192                 .count();
193         fixed |= fixedCombineSelfCount > 0;
194 
195         return fixed;
196     }
197 
198     /**
199      * Fixes duplicate dependencies in dependencies and dependencyManagement sections.
200      */
201     private boolean fixDuplicateDependencies(Document pomDocument, UpgradeContext context) {
202         Element root = pomDocument.root();
203 
204         // Collect all dependency containers to process
205         Stream<DependencyContainer> dependencyContainers = Stream.concat(
206                 // Root level dependencies
207                 Stream.of(
208                                 new DependencyContainer(root.child(DEPENDENCIES).orElse(null), DEPENDENCIES),
209                                 new DependencyContainer(
210                                         root.child(DEPENDENCY_MANAGEMENT)
211                                                 .flatMap(dm -> dm.child(DEPENDENCIES))
212                                                 .orElse(null),
213                                         DEPENDENCY_MANAGEMENT))
214                         .filter(container -> container.element != null),
215                 // Profile dependencies
216                 root.child(PROFILES).stream()
217                         .flatMap(profiles -> profiles.children(PROFILE))
218                         .flatMap(profile -> Stream.of(
219                                         new DependencyContainer(
220                                                 profile.child(DEPENDENCIES).orElse(null), "profile dependencies"),
221                                         new DependencyContainer(
222                                                 profile.child(DEPENDENCY_MANAGEMENT)
223                                                         .flatMap(dm -> dm.child(DEPENDENCIES))
224                                                         .orElse(null),
225                                                 "profile dependencyManagement"))
226                                 .filter(container -> container.element != null)));
227 
228         return dependencyContainers
229                 .map(container -> fixDuplicateDependenciesInSection(container.element, context, container.sectionName))
230                 .reduce(false, Boolean::logicalOr);
231     }
232 
233     private static class DependencyContainer {
234         final Element element;
235         final String sectionName;
236 
237         DependencyContainer(Element element, String sectionName) {
238             this.element = element;
239             this.sectionName = sectionName;
240         }
241     }
242 
243     /**
244      * Fixes duplicate plugins in plugins and pluginManagement sections.
245      */
246     private boolean fixDuplicatePlugins(Document pomDocument, UpgradeContext context) {
247         Element root = pomDocument.root();
248 
249         // Collect all build elements to process
250         Stream<BuildContainer> buildContainers = Stream.concat(
251                 // Root level build
252                 Stream.of(new BuildContainer(root.child(BUILD).orElse(null), BUILD))
253                         .filter(container -> container.element != null),
254                 // Profile builds
255                 root.child(PROFILES).stream()
256                         .flatMap(profiles -> profiles.children(PROFILE))
257                         .map(profile -> new BuildContainer(profile.child(BUILD).orElse(null), "profile build"))
258                         .filter(container -> container.element != null));
259 
260         return buildContainers
261                 .map(container -> fixPluginsInBuildElement(container.element, context, container.sectionName))
262                 .reduce(false, Boolean::logicalOr);
263     }
264 
265     private static class BuildContainer {
266         final Element element;
267         final String sectionName;
268 
269         BuildContainer(Element element, String sectionName) {
270             this.element = element;
271             this.sectionName = sectionName;
272         }
273     }
274 
275     /**
276      * Fixes unsupported repository URL expressions.
277      */
278     private boolean fixUnsupportedRepositoryExpressions(Document pomDocument, UpgradeContext context) {
279         Element root = pomDocument.root();
280 
281         // Collect all repository containers to process
282         Stream<Element> repositoryContainers = Stream.concat(
283                 // Root level repositories
284                 Stream.of(
285                                 root.child(REPOSITORIES).orElse(null),
286                                 root.child(PLUGIN_REPOSITORIES).orElse(null))
287                         .filter(Objects::nonNull),
288                 // Profile repositories
289                 root.child(PROFILES).stream()
290                         .flatMap(profiles -> profiles.children(PROFILE))
291                         .flatMap(profile -> Stream.of(
292                                         profile.child(REPOSITORIES).orElse(null),
293                                         profile.child(PLUGIN_REPOSITORIES).orElse(null))
294                                 .filter(Objects::nonNull)));
295 
296         return repositoryContainers
297                 .map(container -> fixRepositoryExpressions(container, pomDocument, context))
298                 .reduce(false, Boolean::logicalOr);
299     }
300 
301     /**
302      * Fixes incorrect parent relative paths.
303      */
304     private boolean fixIncorrectParentRelativePaths(
305             Document pomDocument, Path pomPath, Map<Path, Document> pomMap, UpgradeContext context) {
306         Element root = pomDocument.root();
307 
308         Element parentElement = root.child(PARENT).orElse(null);
309         if (parentElement == null) {
310             return false; // No parent to fix
311         }
312 
313         Element relativePathElement = parentElement.child(RELATIVE_PATH).orElse(null);
314         String currentRelativePath =
315                 relativePathElement != null ? relativePathElement.textContent().trim() : DEFAULT_PARENT_RELATIVE_PATH;
316 
317         // Try to find the correct parent POM
318         String parentGroupId = parentElement.childText(MavenPomElements.Elements.GROUP_ID);
319         String parentArtifactId = parentElement.childText(MavenPomElements.Elements.ARTIFACT_ID);
320         String parentVersion = parentElement.childText(MavenPomElements.Elements.VERSION);
321 
322         Path correctParentPath = findParentPomInMap(context, parentGroupId, parentArtifactId, parentVersion, pomMap);
323         if (correctParentPath != null) {
324             try {
325                 Path correctRelativePath = pomPath.getParent().relativize(correctParentPath);
326                 String correctRelativePathStr = correctRelativePath.toString().replace('\\', '/');
327 
328                 if (!correctRelativePathStr.equals(currentRelativePath)) {
329                     // Update or create relativePath element using DomUtils convenience method
330                     DomUtils.updateOrCreateChildElement(parentElement, RELATIVE_PATH, correctRelativePathStr);
331                     context.detail("Fixed: " + "relativePath corrected from '" + currentRelativePath + "' to '"
332                             + correctRelativePathStr + "'");
333                     return true;
334                 }
335             } catch (Exception e) {
336                 context.failure("Failed to compute correct relativePath" + ": " + e.getMessage());
337             }
338         }
339 
340         return false;
341     }
342 
343     /**
344      * Recursively finds all elements with a specific attribute value.
345      */
346     private Stream<Element> findElementsWithAttribute(Element element, String attributeName, String attributeValue) {
347         return Stream.concat(
348                 // Check current element
349                 Stream.of(element).filter(e -> {
350                     String attr = e.attribute(attributeName);
351                     return attr != null && attributeValue.equals(attr);
352                 }),
353                 // Recursively check children
354                 element.children().flatMap(child -> findElementsWithAttribute(child, attributeName, attributeValue)));
355     }
356 
357     /**
358      * Helper methods extracted from BaseUpgradeGoal for compatibility fixes.
359      */
360     private boolean fixDuplicateDependenciesInSection(
361             Element dependenciesElement, UpgradeContext context, String sectionName) {
362         List<Element> dependencies = dependenciesElement.children(DEPENDENCY).toList();
363         Map<String, Element> seenDependencies = new HashMap<>();
364 
365         List<Element> duplicates = dependencies.stream()
366                 .filter(dependency -> {
367                     String key = createDependencyKey(dependency);
368                     if (seenDependencies.containsKey(key)) {
369                         context.detail("Fixed: Removed duplicate dependency: " + key + " in " + sectionName);
370                         return true; // This is a duplicate
371                     } else {
372                         seenDependencies.put(key, dependency);
373                         return false; // This is the first occurrence
374                     }
375                 })
376                 .toList();
377 
378         // Remove duplicates while preserving formatting
379         duplicates.forEach(DomUtils::removeElement);
380 
381         return !duplicates.isEmpty();
382     }
383 
384     private String createDependencyKey(Element dependency) {
385         String groupId = dependency.childText(MavenPomElements.Elements.GROUP_ID);
386         String artifactId = dependency.childText(MavenPomElements.Elements.ARTIFACT_ID);
387         String type = dependency.childText(MavenPomElements.Elements.TYPE);
388         String classifier = dependency.childText(MavenPomElements.Elements.CLASSIFIER);
389 
390         return groupId + ":" + artifactId + ":" + (type != null ? type : "jar") + ":"
391                 + (classifier != null ? classifier : "");
392     }
393 
394     private boolean fixPluginsInBuildElement(Element buildElement, UpgradeContext context, String sectionName) {
395         boolean fixed = false;
396 
397         Element pluginsElement = buildElement.child(PLUGINS).orElse(null);
398         if (pluginsElement != null) {
399             fixed |= fixDuplicatePluginsInSection(pluginsElement, context, sectionName + "/" + PLUGINS);
400         }
401 
402         Element pluginManagementElement = buildElement.child(PLUGIN_MANAGEMENT).orElse(null);
403         if (pluginManagementElement != null) {
404             Element managedPluginsElement =
405                     pluginManagementElement.child(PLUGINS).orElse(null);
406             if (managedPluginsElement != null) {
407                 fixed |= fixDuplicatePluginsInSection(
408                         managedPluginsElement, context, sectionName + "/" + PLUGIN_MANAGEMENT + "/" + PLUGINS);
409             }
410         }
411 
412         return fixed;
413     }
414 
415     /**
416      * Fixes duplicate plugins within a specific plugins section.
417      */
418     private boolean fixDuplicatePluginsInSection(Element pluginsElement, UpgradeContext context, String sectionName) {
419         List<Element> plugins = pluginsElement.children(PLUGIN).toList();
420         Map<String, Element> seenPlugins = new HashMap<>();
421 
422         List<Element> duplicates = plugins.stream()
423                 .filter(plugin -> {
424                     String key = createPluginKey(plugin);
425                     if (key != null) {
426                         if (seenPlugins.containsKey(key)) {
427                             context.detail("Fixed: Removed duplicate plugin: " + key + " in " + sectionName);
428                             return true; // This is a duplicate
429                         } else {
430                             seenPlugins.put(key, plugin);
431                         }
432                     }
433                     return false; // This is the first occurrence or invalid plugin
434                 })
435                 .toList();
436 
437         // Remove duplicates while preserving formatting
438         duplicates.forEach(DomUtils::removeElement);
439 
440         return !duplicates.isEmpty();
441     }
442 
443     private String createPluginKey(Element plugin) {
444         String groupId = plugin.childText(MavenPomElements.Elements.GROUP_ID);
445         String artifactId = plugin.childText(MavenPomElements.Elements.ARTIFACT_ID);
446 
447         // Default groupId for Maven plugins
448         if (groupId == null && artifactId != null && artifactId.startsWith(MAVEN_PLUGIN_PREFIX)) {
449             groupId = DEFAULT_MAVEN_PLUGIN_GROUP_ID;
450         }
451 
452         return (groupId != null && artifactId != null) ? groupId + ":" + artifactId : null;
453     }
454 
455     private boolean fixRepositoryExpressions(
456             Element repositoriesElement, Document pomDocument, UpgradeContext context) {
457         if (repositoriesElement == null) {
458             return false;
459         }
460 
461         boolean fixed = false;
462         String elementType = repositoriesElement.name().equals(REPOSITORIES) ? REPOSITORY : PLUGIN_REPOSITORY;
463         List<Element> repositories = repositoriesElement.children(elementType).toList();
464 
465         for (Element repository : repositories) {
466             Element urlElement = repository.child("url").orElse(null);
467             if (urlElement != null) {
468                 String url = urlElement.textContent().trim();
469                 if (url.contains("${")) {
470                     // Allow repository URL interpolation; do not disable.
471                     // Keep a gentle warning to help users notice unresolved placeholders at build time.
472                     String repositoryId = repository.childText("id");
473                     context.info("Detected interpolated expression in " + elementType + " URL (id: " + repositoryId
474                             + "): " + url);
475                 }
476             }
477         }
478 
479         return fixed;
480     }
481 
482     private Path findParentPomInMap(
483             UpgradeContext context, String groupId, String artifactId, String version, Map<Path, Document> pomMap) {
484         return pomMap.entrySet().stream()
485                 .filter(entry -> {
486                     Coordinates gav = AbstractUpgradeStrategy.extractArtifactCoordinatesWithParentResolution(
487                             context, entry.getValue());
488                     return gav != null
489                             && Objects.equals(gav.groupId(), groupId)
490                             && Objects.equals(gav.artifactId(), artifactId)
491                             && (version == null || Objects.equals(gav.version(), version));
492                 })
493                 .findFirst()
494                 .map(Map.Entry::getKey)
495                 .orElse(null);
496     }
497 }