View Javadoc
1   package com.github.mikkoi.maven.enforcer.rules;
2   
3   import javax.annotation.Nullable;
4   import javax.inject.Inject;
5   import javax.inject.Named;
6   
7   import org.apache.maven.enforcer.rule.api.AbstractEnforcerRule;
8   import org.apache.maven.enforcer.rule.api.EnforcerRuleException;
9   import org.apache.maven.execution.MavenSession;
10  import org.apache.maven.model.Dependency;
11  import org.apache.maven.project.MavenProject;
12  
13  import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
14  import java.util.ArrayList;
15  import java.util.Arrays;
16  import java.util.List;
17  import java.util.Objects;
18  import java.util.function.Predicate;
19  
20  /**
21   * Maven Enforcer Custom Rule.
22   */
23  @Named("dependOnAllProjects")
24  public class DependOnAllProjects extends AbstractEnforcerRule {
25  
26      /**
27       * Constant value: Size of indentation inside a <dependency>> definition.
28       */
29      private static final String INDENT_DEPENDENCY = "    ";
30      /**
31       * Constant value: Maximum number of parts in a dependency declaration.
32       */
33      private static final int MAX_NUM_PARTS_IN_DEPENDENCY_DECLARATION = 3;
34      /**
35       * Constant value for faking boolean parameter false.
36       */
37      private static final String FALSE = "false";
38      /**
39       * Constant value for faking boolean parameter true.
40       */
41      private static final String TRUE = "true";
42      /**
43       * Inject needed Maven component.
44        */
45      private final MavenSession mavenSession;
46      /**
47       * Include by project [groupId:]artifactId[:packagingType].
48       * Default value: all projects included.
49       */
50      private List<String> includes;
51      /**
52       * Exclude by project [groupId:]artifactId[:packagingType].
53       * Default value: No projects excluded.
54       * If includes list contains any items, they are evaluated first.
55       * Then excludes are excluded from them.
56       */
57      private List<String> excludes;
58      /**
59       * Error if unknown project in includes/excludes.
60       * If a wildcard (*) is used in the name, this parameter has no effect.
61       */
62      @SuppressWarnings("unused") // Not actually unused. Set via Plexus/Sisu Container.
63      private String errorIfUnknownProject;
64      /**
65       * Include Maven root project.
66       */
67      @SuppressWarnings("unused") // Not actually unused. Set via Plexus/Sisu Container.
68      private String includeRootProject;
69  
70      /**
71       * Constructor.
72       *
73       * @param session            Initialized MavenSession object.
74       */
75      @Inject
76      @SuppressFBWarnings
77      public DependOnAllProjects(MavenSession session) {
78          this.mavenSession = Objects.requireNonNull(session);
79      }
80  
81      /**
82       * Set includes.
83       * @param includes the includes
84       */
85      @Inject
86      public void setIncludes(@Nullable List<String> includes) {
87          if (includes == null) {
88              this.includes = new ArrayList<>();
89          } else {
90              this.includes = new ArrayList<>(includes);
91          }
92      }
93  
94      /**
95       * Set excludes.
96       * @param excludes the excludes
97       */
98      @Inject
99      public void setExcludes(@Nullable List<String> excludes) {
100         if (excludes == null) {
101             this.excludes = new ArrayList<>();
102         } else {
103             this.excludes = new ArrayList<>(excludes);
104         }
105     }
106 
107     /**
108      * Set errorIfUnknownProject.
109      * @param errorIfUnknownProject the errorIfUnknownProject
110      */
111     @Inject
112     public void setErrorIfUnknownProject(String errorIfUnknownProject) {
113         if (errorIfUnknownProject != null) {
114             this.errorIfUnknownProject = errorIfUnknownProject;
115         } else {
116             this.errorIfUnknownProject = FALSE;
117         }
118     }
119 
120     /**
121      * Set includeRootProject.
122      * @param includeRootProject the includeRootProject
123      */
124     @Inject
125     public void setIncludeRootProject(String includeRootProject) {
126         if (includeRootProject != null) {
127             this.includeRootProject = includeRootProject;
128         } else {
129             this.includeRootProject = FALSE;
130         }
131     }
132 
133     /**
134      * Format Dependency object to XML snippet.
135      * User can simply copy-paste this to the project.
136      * @param dependency Dependency object
137      * @param indent     Indentation string, e.g. "    "
138      * @return Formatted XML snippet
139      */
140     public static String formatDependency(Dependency dependency, String indent) {
141         final String newLine = System.lineSeparator();
142         final StringBuilder sb = new StringBuilder();
143         sb.append("<dependency>").append(newLine);
144         sb.append(indent).append(String.format("<groupId>%s</groupId>", dependency.getGroupId()))
145             .append(newLine);
146         sb.append(indent)
147             .append(String.format("<artifactId>%s</artifactId>", dependency.getArtifactId()))
148             .append(newLine);
149         if (!"jar".equals(dependency.getType())) {
150             sb.append(indent).append(String.format("<type>%s</type>", dependency.getType()))
151                 .append(newLine);
152         }
153         sb.append("</dependency>");
154         return sb.toString();
155     }
156 
157     /**
158      * Convert string for matching.
159      *
160      * @param s String
161      * @return converted string
162      */
163     public static String convertStringForMatching(String s) {
164         String t = s.replace(".", "\\.");
165         t = t.replace("*", ".*");
166         if (!t.contains(":")) {
167             t = ".*:" + t + ":.*";
168         }
169         if (Arrays.stream(t.split(":")).count() < MAX_NUM_PARTS_IN_DEPENDENCY_DECLARATION) {
170             t = t + ":.*";
171         }
172         return t;
173     }
174 
175     /**
176      * Compare two MavenProject objects.
177      * GroupId, ArtifactId, Version and Packaging must match.
178      *
179      * @param a MavenProject a
180      * @param b MavenProject b
181      * @return true if projects are equal
182      */
183     public static boolean projectsAreEquals(MavenProject a, MavenProject b) {
184         return a.getGroupId().equals(b.getGroupId())
185             && a.getArtifactId().equals(b.getArtifactId()) && a.getVersion().equals(b.getVersion())
186             && a.getPackaging().equals(b.getPackaging());
187     }
188 
189     /**
190      * Compare two Dependency objects.
191      * GroupId, ArtifactId, Version and Type must match.
192      *
193      * @param a Dependency a
194      * @param b Dependency b
195      * @return true if dependencies are equal
196      */
197     public static boolean dependenciesAreEquals(Dependency a, Dependency b) {
198         return a.getGroupId().equals(b.getGroupId())
199             && a.getArtifactId().equals(b.getArtifactId()) && a.getVersion().equals(b.getVersion())
200             && a.getType().equals(b.getType());
201     }
202 
203     /**
204      * Does the list (Iterable) of Dependency objects contain the MavenProject?
205      *
206      * @param projects Iterable of Dependency objects
207      * @param project  a Maven project object
208      * @return true if project is found
209      */
210     public static boolean dependenciesContains(Iterable<Dependency> projects,
211                                                MavenProject project) {
212         for (Dependency p : projects) {
213             if (dependenciesAreEquals(p, projectToDependency(project))) {
214                 return true;
215             }
216         }
217         return false;
218     }
219 
220     /**
221      * Does any of the projects in the argument (Maven Reactor build) match with this project name?
222      * Attn. No wildcards are supported in projectName.
223      *
224      * @param projects    Iterable of MavenProject objects
225      * @param projectName Project name, e.g. "artifactId", "groupId:artifactId", "groupId:artifactId:packagingType".
226      * @return Boolean
227      */
228     public static boolean projectsContains(Iterable<MavenProject> projects, String projectName) {
229         for (MavenProject project : projects) {
230             if (projectMatchesWithDefinition(project, projectName)) {
231                 return true;
232             }
233         }
234         return false;
235     }
236 
237     /**
238      * Does the MavenProject match with this jar name definition?
239      * Attn. If definition contains only artifactId, groupId and packagingType are ignored.
240      * Attn. If definition contains groupId and artifactId, packagingType is ignored.
241      * Attn. If definition contains groupId, artifactId and packagingType, all are matched.
242      * Attn. No wildcards are supported here.
243      *
244      * @param project           MavenProject object
245      * @param projectDefinition Project name, e.g. "artifactId", "groupId:artifactId", "groupId:artifactId:packagingType".
246      * @return Boolean
247      */
248     public static boolean projectMatchesWithDefinition(MavenProject project, String projectDefinition) {
249         List<String> ids = Arrays.asList(projectDefinition.split(":"));
250         if (ids.size() == 1) {
251             return project.getArtifactId().equals(ids.get(0));
252         } else if (ids.size() == 2) {
253             return project.getGroupId().equals(ids.get(0))
254                 && project.getArtifactId().equals(ids.get(1));
255         } else {
256             // ids.size() is MAX_NUM_PARTS_IN_DEPENDENCY_DECLARATION;
257             return project.getGroupId().equals(ids.get(0))
258                 && project.getArtifactId().equals(ids.get(1))
259                 && project.getPackaging().equals(ids.get(2));
260         }
261     }
262 
263     /**
264      * Convert MavenProject to Dependency object.
265      *
266      * @param mavenProject MavenProject object
267      * @return Dependency object
268      */
269     public static Dependency projectToDependency(MavenProject mavenProject) {
270         Dependency d = new Dependency();
271         d.setGroupId(mavenProject.getGroupId());
272         d.setArtifactId(mavenProject.getArtifactId());
273         d.setVersion(mavenProject.getVersion());
274         d.setType(mavenProject.getPackaging());
275         return d;
276     }
277 
278     /**
279      * Match projects with includes and excludes.
280      * Includes are * by default. Then excludes are excluded from the includes.
281      *
282      * @param includes     Included projects. Default: *
283      * @param excludes     Excluded projects. Default: none
284      * @param mavenProject Initialized MavenProject object.
285      * @return True or false.
286      */
287     public static boolean isProjectIncluded(List<String> includes, List<String> excludes,
288                                             MavenProject mavenProject) {
289         String projectId =
290             String.format("%s:%s:%s", mavenProject.getGroupId(), mavenProject.getArtifactId(),
291                 mavenProject.getPackaging());
292 
293         // Match from the end of the id, artifactId alone is enough.
294         Predicate<String> predicateForProjectId =
295             s -> projectId.matches(convertStringForMatching(s));
296         return includes.stream().anyMatch(predicateForProjectId)
297             && excludes.stream().noneMatch(predicateForProjectId);
298     }
299 
300     /**
301      * Validate parameters provided via properties
302      * either on the command line or using configuration element in pom.
303      *
304      * @throws EnforcerRuleException if parameter validation fails.
305      */
306     void validateAndPrepareParameters() throws EnforcerRuleException {
307         getLog().debug("includes=" + includes);
308         getLog().debug("excludes=" + excludes);
309         getLog().debug("errorIfUnknownProject=" + errorIfUnknownProject);
310         getLog().debug("includeRootProject=" + includeRootProject);
311 
312         final List<MavenProject> reactorProjects =
313             mavenSession.getProjectDependencyGraph().getSortedProjects();
314         getLog().debug("reactorProjects=" + reactorProjects);
315 
316         /* There is a bug in Maven/Sisu/Plexus container, which sets includes to a list with one empty string,
317          * if the parameter is not set. So we need to check for this case and convert it to an empty list.
318          * Then we can add the default value of "*".
319          */
320         if ((includes == null) || (includes.size() == 1 && "".equals(includes.get(0)))) {
321             includes = new ArrayList<>();
322         }
323         if (excludes == null) {
324             excludes = new ArrayList<>();
325         } else if (excludes.size() == 1 && "".equals(excludes.get(0))) {
326             excludes = new ArrayList<>();
327         }
328         getLog().debug(String.format("Parameter includes.size: %d", includes.size()));
329         for (String a : includes) {
330             getLog().debug(String.format("Check include '%s'", a));
331             if (a == null) {
332                 throw new EnforcerRuleException(
333                     "Failure in parameter 'includes'. String is null");
334             }
335             if (a.matches("^[\t\n ]+$")) {
336                 throw new EnforcerRuleException(
337                     String.format("Failure in parameter 'includes'. String contains only whitespace: '%s'", a));
338             }
339             List<String> ids = Arrays.asList(a.split(":"));
340             if (ids.size() > MAX_NUM_PARTS_IN_DEPENDENCY_DECLARATION) {
341                 throw new EnforcerRuleException(
342                     "Failure in parameter 'includes'. String is invalid");
343             }
344             /* If there is a wildcard, we cannot check if the project exists in the build.
345              * So we skip the check in this case.
346              */
347             if (TRUE.equals(errorIfUnknownProject) && !a.contains("*")
348                 && !projectsContains(reactorProjects, a)) {
349                 throw new EnforcerRuleException(String.format(
350                     "Failure in parameter 'includes'. Project '%s' not found in build", a));
351             }
352         }
353         if (includes.isEmpty()) {
354             includes.add("*");
355         }
356 
357         getLog().debug(String.format("Parameter excludes.size: %d", excludes.size()));
358         for (String a : excludes) {
359             getLog().debug(String.format("Check exclude '%s'", a));
360             if (a == null) {
361                 throw new EnforcerRuleException(
362                     "Failure in parameter 'excludes'. String is null");
363             }
364             if (a.matches("^[\t\n ]+$")) {
365                 throw new EnforcerRuleException(
366                     String.format("Failure in parameter 'excludes'. String contains only whitespace: '%s'", a));
367             }
368             List<String> ids = Arrays.asList(a.split(":"));
369             if (ids.size() > MAX_NUM_PARTS_IN_DEPENDENCY_DECLARATION) {
370                 throw new EnforcerRuleException(
371                     "Failure in parameter 'excludes'. String is invalid");
372             }
373             /* If there is a wildcard, we cannot check if the project exists in the build.
374              * So we skip the check in this case.
375              */
376             if (TRUE.equals(errorIfUnknownProject) && !a.contains("*")
377                 && !projectsContains(reactorProjects, a)) {
378                 throw new EnforcerRuleException(String.format(
379                     "Failure in parameter 'excludes'. Project '%s' not found in build", a));
380             }
381         }
382 
383         if (errorIfUnknownProject == null || errorIfUnknownProject.isEmpty()) {
384             errorIfUnknownProject = FALSE;
385         }
386         if (includeRootProject == null || includeRootProject.isEmpty()) {
387             includeRootProject = FALSE;
388         }
389         if (!TRUE.equals(errorIfUnknownProject) && !FALSE.equals(errorIfUnknownProject)) {
390             throw new EnforcerRuleException(
391                 String.format("Failure in parameter 'errorIfUnknownProject'. Must be 'true' or 'false': '%s'", errorIfUnknownProject));
392         }
393         if (!TRUE.equals(includeRootProject) && !FALSE.equals(includeRootProject)) {
394             throw new EnforcerRuleException(
395                 String.format("Failure in parameter 'includeRootProject'. Must be 'true' or 'false': '%s'", includeRootProject));
396         }
397 
398         getLog().debug("includes(resolved)=" + includes);
399         getLog().debug("excludes(resolved)=" + excludes);
400         getLog().debug("errorIfUnknownProject(resolved)=" + errorIfUnknownProject);
401         getLog().debug("includeRootProject(resolved)=" + includeRootProject);
402     }
403 
404     /**
405      * The rule logic.
406      * Collect all projects in the build and filter according to includes/excludes.
407      * Match the list with the dependencies of the current project.
408      * If the two lists do not match, Raise EnforcerRuleException
409      *
410      * @throws EnforcerRuleException if rule fails.
411      */
412     public void dependOnAllProjects() throws EnforcerRuleException {
413         List<MavenProject> includedProjects = new ArrayList<>();
414         MavenProject currentProject = mavenSession.getCurrentProject();
415         getLog().debug(String.format("Current Project: %s:%s", currentProject.getGroupId(),
416             currentProject.getArtifactId()));
417 
418         getLog().debug("Iterate through all projects in Maven Dependency Graph, i.e. the build.");
419         mavenSession.getProjectDependencyGraph().getSortedProjects().forEach(project -> {
420             String projectId =
421                 String.format("%s:%s:%s", project.getGroupId(), project.getArtifactId(),
422                     project.getVersion());
423             getLog().debug("    " + projectId);
424 
425             if (isIncluded(project)) {
426                 // Filter out current project and optionally root project (if includeRootProject is false)
427                 if (projectsAreEquals(project, currentProject) || (!TRUE.equals(this.includeRootProject)
428                     && projectsAreEquals(project, mavenSession.getTopLevelProject()))) {
429                     getLog().debug("Filter out project: "
430                         + String.format("%s:%s", project.getGroupId(), project.getArtifactId()));
431                 } else {
432                     includedProjects.add(project);
433                 }
434             }
435         });
436         getLog().debug("includedProjects=%s" + includedProjects);
437         List<MavenProject> missingProjects = new ArrayList<>();
438         for (MavenProject project : includedProjects) {
439             List<Dependency> dependencies =
440                 currentProject.getDependencies();
441             if (!dependenciesContains(dependencies, project)) {
442                 missingProjects.add(project);
443             }
444         }
445         if (!missingProjects.isEmpty()) {
446             List<String> errors = new ArrayList<>(missingProjects.size());
447             for (MavenProject missingProject : missingProjects) {
448                 errors.add(String.format("Project '%s:%s' is missing dependency '%s:%s:%s'.",
449                     currentProject.getGroupId(), currentProject.getArtifactId(),
450                     missingProject.getGroupId(), missingProject.getArtifactId(),
451                     missingProject.getPackaging()));
452             }
453             StringBuilder sb = new StringBuilder();
454             sb.append(String.format("Missing definitions from the project '%s:%s':",
455                 currentProject.getGroupId(), currentProject.getArtifactId()));
456             sb.append(System.lineSeparator());
457             sb.append("<!--     Created by Maven Enforcer rule dependOnAllProjects     --->");
458             sb.append(System.lineSeparator());
459             for (MavenProject missingProject : missingProjects) {
460                 sb.append(formatDependency(projectToDependency(missingProject), INDENT_DEPENDENCY));
461                 sb.append(System.lineSeparator());
462             }
463             sb.append("<!--     / Created by Maven Enforcer rule dependOnAllProjects     --->");
464             errors.add(sb.toString());
465             throw new EnforcerRuleException(String.join("\n", errors));
466         }
467         getLog().debug("End of iterate");
468     }
469 
470     /**
471      * The main entry point for rule.
472      *
473      * @throws EnforcerRuleException if rule fails.
474      */
475     @Override
476     public void execute() throws EnforcerRuleException {
477         MavenProject currentProject = mavenSession.getCurrentProject();
478         getLog().debug(String.format("Current Project: %s:%s", currentProject.getGroupId(),
479             currentProject.getArtifactId()));
480         MavenProject topLevelProject = mavenSession.getTopLevelProject();
481         getLog().debug(String.format("Top Level Project: %s:%s", topLevelProject.getGroupId(),
482             topLevelProject.getArtifactId()));
483 
484         validateAndPrepareParameters();
485 
486         dependOnAllProjects();
487     }
488 
489     /**
490      * String representation of the rule.
491      * Output is used in verbose Maven logs, can help during investigate problems.
492      *
493      * @return rule description
494      */
495     @Override
496     public String toString() {
497         return String.format(
498             "DependOnAllProjects[includes=%s;excludes=%s;includeRootProject=%s;errorIfUnknownProject=%s]",
499             includes, excludes, includeRootProject, errorIfUnknownProject);
500     }
501 
502     /**
503      * Decide if the project is included or excluded.
504      *
505      * @param mavenProject MavenProject
506      * @return true if included, false if excluded
507      */
508     boolean isIncluded(MavenProject mavenProject) {
509         boolean r = isProjectIncluded(this.includes, this.excludes, mavenProject);
510         getLog().debug(String.format("isIncluded(%s:%s:%s:%s): %b", mavenProject.getGroupId(),
511             mavenProject.getArtifactId(), mavenProject.getVersion(), mavenProject.getPackaging(),
512             r));
513         return r;
514     }
515 
516 }