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 Maven Reactor build match with this project name?
222      * Name must not have a wildcard!
223      *
224      * @param projects    Iterable of MavenProject objects
225      * @param projectName Project name, e.g. "artifactId", "groupId:artifactId", "groupId:artifactId:packingType".
226      * @return Boolean
227      */
228     public static boolean projectsContains(Iterable<MavenProject> projects, String projectName) {
229         List<String> ids = Arrays.asList(projectName.split(":"));
230         for (MavenProject project : projects) {
231             if (ids.size() == 1) {
232                 if (project.getArtifactId().equals(ids.get(0))) {
233                     return true;
234                 }
235             } else if (ids.size() == 2) {
236                 if (project.getGroupId().equals(ids.get(0))
237                     && project.getArtifactId().equals(ids.get(1))) {
238                     return true;
239                 }
240             } else {
241                 assert ids.size() == MAX_NUM_PARTS_IN_DEPENDENCY_DECLARATION;
242                 if (project.getGroupId().equals(ids.get(0))
243                     && project.getArtifactId().equals(ids.get(1))
244                     && project.getPackaging().equals(ids.get(2))) {
245                     return true;
246                 }
247             }
248         }
249         return false;
250     }
251 
252     /**
253      * Convert MavenProject to Dependency object.
254      *
255      * @param mavenProject MavenProject object
256      * @return Dependency object
257      */
258     public static Dependency projectToDependency(MavenProject mavenProject) {
259         Dependency d = new Dependency();
260         d.setGroupId(mavenProject.getGroupId());
261         d.setArtifactId(mavenProject.getArtifactId());
262         d.setVersion(mavenProject.getVersion());
263         d.setType(mavenProject.getPackaging());
264         return d;
265     }
266 
267     /**
268      * Match projects with includes and excludes.
269      * Includes are * by default. Then excludes are excluded from the includes.
270      *
271      * @param includes     Included projects. Default: *
272      * @param excludes     Excluded projects. Default: none
273      * @param mavenProject Initialized MavenProject object.
274      * @return True or false.
275      */
276     public static boolean isProjectIncluded(List<String> includes, List<String> excludes,
277                                             MavenProject mavenProject) {
278         String projectId =
279             String.format("%s:%s:%s", mavenProject.getGroupId(), mavenProject.getArtifactId(),
280                 mavenProject.getPackaging());
281 
282         // Match from the end of the id, artifactId alone is enough.
283         Predicate<String> predicateForProjectId =
284             s -> projectId.matches(convertStringForMatching(s));
285         return includes.stream().anyMatch(predicateForProjectId)
286             && excludes.stream().noneMatch(predicateForProjectId);
287     }
288 
289     /**
290      * Validate parameters provided via properties
291      * either on the command line or using configuration element in pom.
292      *
293      * @throws EnforcerRuleException if parameter validation fails.
294      */
295     void validateAndPrepareParameters() throws EnforcerRuleException {
296         getLog().debug("includes=" + includes);
297         getLog().debug("excludes=" + excludes);
298         getLog().debug("errorIfUnknownProject=" + errorIfUnknownProject);
299         getLog().debug("includeRootProject=" + includeRootProject);
300 
301         final List<MavenProject> reactorProjects =
302             mavenSession.getProjectDependencyGraph().getSortedProjects();
303         getLog().debug("reactorProjects=" + reactorProjects);
304 
305         /* There is a bug in Maven/Sisu/Plexus container, which sets includes to a list with one empty string,
306          * if the parameter is not set. So we need to check for this case and convert it to an empty list.
307          * Then we can add the default value of "*".
308          */
309         if ((includes == null) || (includes.size() == 1 && "".equals(includes.get(0)))) {
310             includes = new ArrayList<>();
311         }
312         if (excludes == null) {
313             excludes = new ArrayList<>();
314         } else if (excludes.size() == 1 && "".equals(excludes.get(0))) {
315             excludes = new ArrayList<>();
316         }
317         getLog().debug(String.format("Parameter includes.size: %d", includes.size()));
318         for (String a : includes) {
319             getLog().debug(String.format("Check include '%s'", a));
320             if (a == null) {
321                 throw new EnforcerRuleException(
322                     "Failure in parameter 'includes'. String is null");
323             }
324             if (a.matches("^[\t\n ]+$")) {
325                 throw new EnforcerRuleException(
326                     String.format("Failure in parameter 'includes'. String contains only whitespace: '%s'", a));
327             }
328             List<String> ids = Arrays.asList(a.split(":"));
329             if (ids.size() > MAX_NUM_PARTS_IN_DEPENDENCY_DECLARATION) {
330                 throw new EnforcerRuleException(
331                     "Failure in parameter 'includes'. String is invalid");
332             }
333             /* If there is a wildcard, we cannot check if the project exists in the build.
334              * So we skip the check in this case.
335              */
336             if (TRUE.equals(errorIfUnknownProject) && !a.contains("*")
337                 && !projectsContains(reactorProjects, a)) {
338                 throw new EnforcerRuleException(String.format(
339                     "Failure in parameter 'includes'. Project '%s' not found in build", a));
340             }
341         }
342         if (includes.isEmpty()) {
343             includes.add("*");
344         }
345 
346         getLog().debug(String.format("Parameter excludes.size: %d", excludes.size()));
347         for (String a : excludes) {
348             getLog().debug(String.format("Check exclude '%s'", a));
349             if (a == null) {
350                 throw new EnforcerRuleException(
351                     "Failure in parameter 'excludes'. String is null");
352             }
353             if (a.matches("^[\t\n ]+$")) {
354                 throw new EnforcerRuleException(
355                     String.format("Failure in parameter 'excludes'. String contains only whitespace: '%s'", a));
356             }
357             List<String> ids = Arrays.asList(a.split(":"));
358             if (ids.size() > MAX_NUM_PARTS_IN_DEPENDENCY_DECLARATION) {
359                 throw new EnforcerRuleException(
360                     "Failure in parameter 'excludes'. String is invalid");
361             }
362             /* If there is a wildcard, we cannot check if the project exists in the build.
363              * So we skip the check in this case.
364              */
365             if (TRUE.equals(errorIfUnknownProject) && !a.contains("*")
366                 && !projectsContains(reactorProjects, a)) {
367                 throw new EnforcerRuleException(String.format(
368                     "Failure in parameter 'excludes'. Project '%s' not found in build", a));
369             }
370         }
371 
372         if (errorIfUnknownProject == null || errorIfUnknownProject.isEmpty()) {
373             errorIfUnknownProject = FALSE;
374         }
375         if (includeRootProject == null || includeRootProject.isEmpty()) {
376             includeRootProject = FALSE;
377         }
378         if (!TRUE.equals(errorIfUnknownProject) && !FALSE.equals(errorIfUnknownProject)) {
379             throw new EnforcerRuleException(
380                 String.format("Failure in parameter 'errorIfUnknownProject'. Must be 'true' or 'false': '%s'", errorIfUnknownProject));
381         }
382         if (!TRUE.equals(includeRootProject) && !FALSE.equals(includeRootProject)) {
383             throw new EnforcerRuleException(
384                 String.format("Failure in parameter 'includeRootProject'. Must be 'true' or 'false': '%s'", includeRootProject));
385         }
386 
387         getLog().debug("includes(resolved)=" + includes);
388         getLog().debug("excludes(resolved)=" + excludes);
389         getLog().debug("errorIfUnknownProject(resolved)=" + errorIfUnknownProject);
390         getLog().debug("includeRootProject(resolved)=" + includeRootProject);
391     }
392 
393     /**
394      * The rule logic.
395      * Collect all projects in the build and filter according to includes/excludes.
396      * Match the list with the dependencies of the current project.
397      * If the two lists do not match, Raise EnforcerRuleException
398      *
399      * @throws EnforcerRuleException if rule fails.
400      */
401     public void dependOnAllProjects() throws EnforcerRuleException {
402         List<MavenProject> includedProjects = new ArrayList<>();
403         MavenProject currentProject = mavenSession.getCurrentProject();
404         getLog().debug(String.format("Current Project: %s:%s", currentProject.getGroupId(),
405             currentProject.getArtifactId()));
406 
407         getLog().debug("Iterate through all projects in Maven Dependency Graph, i.e. the build.");
408         mavenSession.getProjectDependencyGraph().getSortedProjects().forEach(project -> {
409             String projectId =
410                 String.format("%s:%s:%s", project.getGroupId(), project.getArtifactId(),
411                     project.getVersion());
412             getLog().debug("    " + projectId);
413 
414             if (isIncluded(project)) {
415                 if (projectsAreEquals(project, currentProject) || (!TRUE.equals(this.includeRootProject)
416                     && projectsAreEquals(project, mavenSession.getTopLevelProject()))) {
417                     getLog().debug("Filter out project: "
418                         + String.format("%s:%s", project.getGroupId(), project.getArtifactId()));
419                 } else {
420                     includedProjects.add(project);
421                 }
422             }
423         });
424         getLog().debug("includedProjects=%s" + includedProjects);
425         List<MavenProject> missingProjects = new ArrayList<>();
426         for (MavenProject project : includedProjects) {
427             @SuppressWarnings("unchecked") List<Dependency> dependencies =
428                 currentProject.getDependencies();
429             if (!dependenciesContains(dependencies, project)) {
430                 missingProjects.add(project);
431             }
432         }
433         if (!missingProjects.isEmpty()) {
434             List<String> errors = new ArrayList<>(missingProjects.size());
435             for (MavenProject missingProject : missingProjects) {
436                 errors.add(String.format("Project '%s:%s' is missing dependency '%s:%s:%s'.",
437                     currentProject.getGroupId(), currentProject.getArtifactId(),
438                     missingProject.getGroupId(), missingProject.getArtifactId(),
439                     missingProject.getPackaging()));
440             }
441             StringBuilder sb = new StringBuilder();
442             sb.append(String.format("Missing definitions from the project '%s:%s':",
443                 currentProject.getGroupId(), currentProject.getArtifactId()));
444             sb.append(System.lineSeparator());
445             sb.append("<!--     Created by Maven Enforcer rule dependOnAllProjects     --->");
446             sb.append(System.lineSeparator());
447             for (MavenProject missingProject : missingProjects) {
448                 sb.append(formatDependency(projectToDependency(missingProject), INDENT_DEPENDENCY));
449                 sb.append(System.lineSeparator());
450             }
451             sb.append("<!--     / Created by Maven Enforcer rule dependOnAllProjects     --->");
452             errors.add(sb.toString());
453             throw new EnforcerRuleException(String.join("\n", errors));
454         }
455         getLog().debug("End of iterate");
456     }
457 
458     /**
459      * The main entry point for rule.
460      *
461      * @throws EnforcerRuleException if rule fails.
462      */
463     @Override
464     public void execute() throws EnforcerRuleException {
465         MavenProject currentProject = mavenSession.getCurrentProject();
466         getLog().debug(String.format("Current Project: %s:%s", currentProject.getGroupId(),
467             currentProject.getArtifactId()));
468         MavenProject topLevelProject = mavenSession.getTopLevelProject();
469         getLog().debug(String.format("Top Level Project: %s:%s", topLevelProject.getGroupId(),
470             topLevelProject.getArtifactId()));
471 
472         validateAndPrepareParameters();
473 
474         dependOnAllProjects();
475     }
476 
477     /**
478      * String representation of the rule.
479      * Output is used in verbose Maven logs, can help during investigate problems.
480      *
481      * @return rule description
482      */
483     @Override
484     public String toString() {
485         return String.format(
486             "DependOnAllProjects[includes=%s;excludes=%s;includeRootProject=%s;errorIfUnknownProject=%s]",
487             includes, excludes, includeRootProject, errorIfUnknownProject);
488     }
489 
490     /**
491      * Decide if the project is included or excluded.
492      *
493      * @param mavenProject MavenProject
494      * @return true if included, false if excluded
495      */
496     boolean isIncluded(MavenProject mavenProject) {
497         boolean r = isProjectIncluded(this.includes, this.excludes, mavenProject);
498         getLog().debug(String.format("isIncluded(%s:%s:%s:%s): %b", mavenProject.getGroupId(),
499             mavenProject.getArtifactId(), mavenProject.getVersion(), mavenProject.getPackaging(),
500             r));
501         return r;
502     }
503 
504 }