View Javadoc
1   /*
2    * SPDX-FileCopyrightText: Copyright (c) 2011-2026 Yegor Bugayenko
3    * SPDX-License-Identifier: MIT
4    */
5   package com.qulice.maven;
6   
7   import com.google.common.base.Predicate;
8   import com.google.common.base.Predicates;
9   import com.google.common.collect.Collections2;
10  import com.jcabi.log.Logger;
11  import com.qulice.spi.ValidationException;
12  import java.io.File;
13  import java.io.IOException;
14  import java.nio.charset.StandardCharsets;
15  import java.nio.file.Files;
16  import java.nio.file.Path;
17  import java.nio.file.Paths;
18  import java.util.Collection;
19  import java.util.Enumeration;
20  import java.util.HashSet;
21  import java.util.LinkedList;
22  import java.util.Set;
23  import java.util.jar.JarEntry;
24  import java.util.jar.JarFile;
25  import java.util.stream.Stream;
26  import org.apache.maven.artifact.Artifact;
27  import org.apache.maven.shared.dependency.analyzer.ProjectDependencyAnalysis;
28  import org.apache.maven.shared.dependency.analyzer.ProjectDependencyAnalyzer;
29  import org.apache.maven.shared.dependency.analyzer.ProjectDependencyAnalyzerException;
30  import org.cactoos.text.Joined;
31  import org.codehaus.plexus.PlexusConstants;
32  import org.codehaus.plexus.PlexusContainer;
33  import org.codehaus.plexus.component.repository.exception.ComponentLookupException;
34  import org.codehaus.plexus.context.ContextException;
35  
36  /**
37   * Validator of dependencies.
38   * @since 0.3
39   * @checkstyle ReturnCountCheck (100 line)
40   */
41  final class DependenciesValidator implements MavenValidator {
42  
43      /**
44       * Separator between lines.
45       */
46      private static final String SEP = String.format("%n\t");
47  
48      @Override
49      @SuppressWarnings("PMD.OnlyOneReturn")
50      public void validate(final MavenEnvironment env)
51          throws ValidationException {
52          if (!env.outdir().exists() || "pom".equals(env.project().getPackaging())) {
53              Logger.info(this, "No dependency analysis in this project");
54              return;
55          }
56          final Collection<String> excludes = env.excludes("dependencies");
57          if (excludes.contains(".*")) {
58              Logger.info(this, "Dependency analysis suppressed in the project via pom.xml");
59              return;
60          }
61          final Collection<String> unused = Collections2.filter(
62              DependenciesValidator.unused(env),
63              Predicates.not(new DependenciesValidator.ExcludePredicate(excludes))
64          );
65          if (!unused.isEmpty()) {
66              Logger.warn(
67                  this,
68                  "Unused declared dependencies found:%s%s",
69                  DependenciesValidator.SEP,
70                  new Joined(DependenciesValidator.SEP, unused).toString()
71              );
72          }
73          final Collection<String> used = Collections2.filter(
74              DependenciesValidator.used(env),
75              Predicates.not(new DependenciesValidator.ExcludePredicate(excludes))
76          );
77          if (!used.isEmpty()) {
78              Logger.warn(
79                  this,
80                  "Used undeclared dependencies found:%s%s",
81                  DependenciesValidator.SEP,
82                  new Joined(DependenciesValidator.SEP, used)
83              );
84          }
85          if (!used.isEmpty() || !unused.isEmpty()) {
86              Logger.info(
87                  this,
88                  "You can suppress this message by <exclude>dependencies:...</exclude> in pom.xml, where <...> is what the dependency name starts with (not a regular expression!)"
89              );
90          }
91          final int failures = used.size() + unused.size();
92          if (failures > 0) {
93              throw new ValidationException(
94                  String.format("%d dependency problem(s) found", failures)
95              );
96          }
97          Logger.info(this, "No dependency problems found");
98      }
99  
100     /**
101      * Analyze the project.
102      * @param env The environment
103      * @return The result of analysis
104      */
105     private static ProjectDependencyAnalysis analyze(
106         final MavenEnvironment env) {
107         try {
108             return ((ProjectDependencyAnalyzer)
109                 ((PlexusContainer)
110                     env.context().get(PlexusConstants.PLEXUS_KEY)
111                 ).lookup(ProjectDependencyAnalyzer.class.getName(), "default")
112             ).analyze(env.project());
113         } catch (final ContextException | ComponentLookupException
114             | ProjectDependencyAnalyzerException ex) {
115             throw new IllegalStateException(ex);
116         }
117     }
118 
119     /**
120      * Find unused artifacts.
121      * @param env Environment
122      * @return Collection of unused artifacts
123      */
124     private static Collection<String> used(final MavenEnvironment env) {
125         final ProjectDependencyAnalysis analysis =
126             DependenciesValidator.analyze(env);
127         final Collection<String> used = new LinkedList<>();
128         for (final Object artifact : analysis.getUsedUndeclaredArtifacts()) {
129             used.add(artifact.toString());
130         }
131         return used;
132     }
133 
134     /**
135      * Find unused artifacts.
136      *
137      * <p>Bytecode analysis cannot detect dependencies used only through
138      * annotations with source retention or through inlined compile-time
139      * constants. To avoid such false positives, artifacts flagged as
140      * unused by {@link ProjectDependencyAnalyzer} are cross-checked
141      * against {@code import} statements in the project's source files;
142      * any artifact referenced by an import is treated as used.</p>
143      *
144      * @param env Environment
145      * @return Collection of unused artifacts
146      */
147     private static Collection<String> unused(final MavenEnvironment env) {
148         final ProjectDependencyAnalysis analysis =
149             DependenciesValidator.analyze(env);
150         final Set<String> imports = DependenciesValidator.imports(env);
151         final Collection<String> unused = new LinkedList<>();
152         for (final Object obj : analysis.getUnusedDeclaredArtifacts()) {
153             final Artifact artifact = (Artifact) obj;
154             if (!Artifact.SCOPE_COMPILE.equals(artifact.getScope())) {
155                 continue;
156             }
157             if (DependenciesValidator.imported(imports, artifact)) {
158                 Logger.info(
159                     DependenciesValidator.class,
160                     "Dependency %s is imported in source and treated as used (annotations or inlined constants are invisible to bytecode analysis)",
161                     artifact
162                 );
163                 continue;
164             }
165             unused.add(artifact.toString());
166         }
167         return unused;
168     }
169 
170     /**
171      * Collect fully-qualified imports from all Java source files
172      * in the project's compile source roots.
173      * @param env Environment
174      * @return Set of imported class names and wildcard package imports
175      */
176     private static Set<String> imports(final MavenEnvironment env) {
177         final Set<String> imports = new HashSet<>();
178         final Collection<String> roots =
179             env.project().getCompileSourceRoots();
180         if (roots != null) {
181             for (final String root : roots) {
182                 final Path dir = Paths.get(root);
183                 if (Files.isDirectory(dir)) {
184                     DependenciesValidator.scanJavaFiles(dir, imports);
185                 }
186             }
187         }
188         return imports;
189     }
190 
191     /**
192      * Walk the given directory and collect imports from every Java file.
193      * @param dir Source root directory
194      * @param acc Accumulator to populate with imports
195      */
196     private static void scanJavaFiles(final Path dir, final Set<String> acc) {
197         try (Stream<Path> walk = Files.walk(dir)) {
198             walk
199                 .filter(path -> path.toString().endsWith(".java"))
200                 .forEach(path -> DependenciesValidator.readImports(path, acc));
201         } catch (final IOException ex) {
202             throw new IllegalStateException(
203                 String.format("Cannot scan source root %s", dir), ex
204             );
205         }
206     }
207 
208     /**
209      * Read import statements from a single Java source file into the
210      * given accumulator.
211      * @param file Java source file
212      * @param acc Accumulator to populate
213      */
214     private static void readImports(final Path file, final Set<String> acc) {
215         try {
216             for (final String line : Files.readAllLines(file, StandardCharsets.UTF_8)) {
217                 final String trimmed = line.trim();
218                 if (!trimmed.startsWith("import ")) {
219                     continue;
220                 }
221                 final int semi = trimmed.indexOf(';');
222                 if (semi < 0) {
223                     continue;
224                 }
225                 String spec =
226                     trimmed.substring("import ".length(), semi).trim();
227                 if (spec.startsWith("static ")) {
228                     spec = spec.substring("static ".length()).trim();
229                     final int dot = spec.lastIndexOf('.');
230                     if (dot > 0) {
231                         spec = spec.substring(0, dot);
232                     }
233                 }
234                 if (!spec.isEmpty()) {
235                     acc.add(spec);
236                 }
237             }
238         } catch (final IOException ex) {
239             throw new IllegalStateException(
240                 String.format("Cannot read source file %s", file), ex
241             );
242         }
243     }
244 
245     /**
246      * Is the given artifact referenced by any of the collected imports?
247      * @param imports Imports collected from project sources
248      * @param artifact Artifact whose JAR is inspected
249      * @return TRUE if at least one class from the JAR is imported
250      */
251     private static boolean imported(final Set<String> imports,
252         final Artifact artifact) {
253         final File file = artifact.getFile();
254         boolean found = false;
255         if (!imports.isEmpty() && file != null && file.isFile()) {
256             try (JarFile jar = new JarFile(file)) {
257                 final Enumeration<JarEntry> entries = jar.entries();
258                 while (!found && entries.hasMoreElements()) {
259                     found = DependenciesValidator.matches(
260                         imports, entries.nextElement().getName()
261                     );
262                 }
263             } catch (final IOException ex) {
264                 Logger.warn(
265                     DependenciesValidator.class,
266                     "Cannot inspect %s while cross-checking imports: %s",
267                     file, ex.getMessage()
268                 );
269             }
270         }
271         return found;
272     }
273 
274     /**
275      * Does the given JAR entry name represent a class imported by the
276      * project sources?
277      * @param imports Imports collected from project sources
278      * @param entry JAR entry name (e.g. "com/example/Foo.class")
279      * @return TRUE if the entry's fully-qualified class name or its
280      *  package is imported
281      */
282     private static boolean matches(final Set<String> imports,
283         final String entry) {
284         boolean match = false;
285         if (entry.endsWith(".class")
286             && !"module-info.class".equals(entry)
287             && entry.indexOf('$') < 0) {
288             final String fqn = entry
289                 .substring(0, entry.length() - ".class".length())
290                 .replace('/', '.');
291             final int dot = fqn.lastIndexOf('.');
292             match = imports.contains(fqn)
293                 || dot > 0
294                 && imports.contains(fqn.substring(0, dot).concat(".*"));
295         }
296         return match;
297     }
298 
299     /**
300      * Predicate for excluded dependencies.
301      * @since 0.1
302      */
303     private static class ExcludePredicate implements Predicate<String> {
304 
305         /**
306          * List of excludes.
307          */
308         private final Collection<String> excludes;
309 
310         /**
311          * Constructor.
312          * @param excludes List of excludes
313          */
314         ExcludePredicate(final Collection<String> excludes) {
315             this.excludes = excludes;
316         }
317 
318         @Override
319         public boolean apply(final String name) {
320             boolean ignore = false;
321             for (final String exclude : this.excludes) {
322                 if (name.startsWith(exclude)) {
323                     ignore = true;
324                     break;
325                 }
326             }
327             return ignore;
328         }
329     }
330 }