View Javadoc
1   /*
2    * SPDX-FileCopyrightText: Copyright (c) 2011-2026 Yegor Bugayenko
3    * SPDX-License-Identifier: MIT
4    */
5   package com.qulice.errorprone;
6   
7   import com.google.common.base.Splitter;
8   import com.jcabi.log.Logger;
9   import com.qulice.spi.Environment;
10  import com.qulice.spi.Ignored;
11  import com.qulice.spi.Relative;
12  import com.qulice.spi.ResourceValidator;
13  import com.qulice.spi.Violation;
14  import com.yegor256.Jaxec;
15  import com.yegor256.Result;
16  import java.io.File;
17  import java.net.URL;
18  import java.net.URLClassLoader;
19  import java.security.CodeSource;
20  import java.util.ArrayList;
21  import java.util.Collection;
22  import java.util.LinkedHashSet;
23  import java.util.List;
24  import java.util.Map;
25  import java.util.Set;
26  import java.util.regex.Pattern;
27  import javax.inject.Inject;
28  
29  /**
30   * Validates source code with Google ErrorProne.
31   *
32   * <p>Runs the {@code javac} executable from the active JDK as a forked
33   * process, with ErrorProne wired in as a {@code -Xplugin:ErrorProne} so
34   * that every bug pattern fires while {@code javac} type-checks the
35   * project's Java sources. The {@code --add-exports} and {@code --add-opens}
36   * flags ErrorProne needs to reach internal {@code jdk.compiler} packages
37   * are passed to the forked JVM via {@code javac}'s {@code -J} prefix; the
38   * JVM hosting Maven and Qulice is unaffected, so consumers do not have to
39   * touch their own {@code .mvn/jvm.config} to use this validator.</p>
40   *
41   * <p>The combined stdout/stderr stream is handed to {@link Diagnostics},
42   * which turns every diagnostic it finds there into a {@link Violation} —
43   * ErrorProne findings, {@code javac} lint warnings and plain compile
44   * errors alike — and passes over the carets, symbol hints and counters
45   * the compiler prints around them. {@code -proc:none} is passed to keep
46   * regular annotation processors (Lombok, Hibernate-Validator, etc.) out
47   * of the ErrorProne pass.</p>
48   *
49   * <p>Which bug patterns fire is up to {@link Xplugin}, which also takes
50   * in the {@code -Xep} flags of the project, read from the
51   * {@code qulice.errorprone} parameter.</p>
52   *
53   * <p>The sources are not fed to one {@code javac} pass but to as many as
54   * the project has source roots, which is what {@link Batches} works out;
55   * the name of each batch tells the passes apart on disk, both in the
56   * argfile they read and in the directory they write classes to.</p>
57   *
58   * @since 1.0
59   */
60  public final class ErrorProneValidator implements ResourceValidator {
61  
62      /**
63       * JVM module-access flags ErrorProne requires to reach internal
64       * {@code jdk.compiler} APIs. Forwarded to the embedded JVM via
65       * {@code javac}'s {@code -J} prefix.
66       */
67      private static final List<String> JVM_FLAGS = List.of(
68          "--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
69          "--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
70          "--add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
71          "--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
72          "--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
73          "--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED",
74          "--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED",
75          "--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED",
76          "--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
77          "--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
78          "--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
79          "--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED"
80      );
81  
82      /**
83       * Name of the parameter through which a project supplies ErrorProne
84       * flags of its own, e.g. {@code -Xep:UnusedVariable:OFF}.
85       */
86      private static final String PARAM = "qulice.errorprone";
87  
88      /**
89       * Splits a multi-line stdout block into individual lines, on any
90       * line terminator (\\n, \\r, \\r\\n, etc.).
91       */
92      private static final Pattern NEWLINE = Pattern.compile("\\R");
93  
94      /**
95       * Environment to use.
96       */
97      private final Environment env;
98  
99      /**
100      * Constructor.
101      *
102      * @param env Environment to use
103      */
104     public ErrorProneValidator(final Environment env) {
105         this.env = env;
106     }
107 
108     @Override
109     public Collection<Violation> validate(final Collection<File> files) {
110         final List<File> sources = this.relevant(files);
111         final Collection<Violation> violations = new ArrayList<>(0);
112         if (sources.isEmpty()) {
113             Logger.debug(
114                 this,
115                 "No files to check with ErrorProne, all %d are excluded",
116                 files.size()
117             );
118         } else {
119             Logger.debug(this, "ErrorProne processing %d files", sources.size());
120             final Diagnostics diagnostics = new Diagnostics(
121                 this.name(), this.env.basedir().getAbsolutePath()
122             );
123             for (final Map.Entry<String, List<File>> batch
124                 : new Batches(this.env, sources).split().entrySet()) {
125                 violations.addAll(
126                     diagnostics.violations(
127                         this.run(batch.getKey(), batch.getValue())
128                     )
129                 );
130             }
131             Logger.debug(this, "ErrorProne processed %d files", sources.size());
132         }
133         return violations;
134     }
135 
136     @Override
137     public String name() {
138         return "ErrorProne";
139     }
140 
141     @Override
142     public int rules() {
143         return new Xplugin(
144             this.env.param(ErrorProneValidator.PARAM, "")
145         ).patterns();
146     }
147 
148     private List<String> run(final String batch, final List<File> sources) {
149         final Result result = new Jaxec(this.command(batch, sources))
150             .withRedirect(true)
151             .withCheck(false)
152             .exec();
153         final String stdout = result.stdout();
154         final List<String> lines;
155         if (stdout.isEmpty()) {
156             lines = List.of();
157         } else {
158             lines = List.of(ErrorProneValidator.NEWLINE.split(stdout));
159         }
160         return lines;
161     }
162 
163     private List<String> command(final String batch, final List<File> sources) {
164         final List<String> command = new ArrayList<>(
165             ErrorProneValidator.JVM_FLAGS.size() + 2
166         );
167         command.add(ErrorProneValidator.javac());
168         for (final String flag : ErrorProneValidator.JVM_FLAGS) {
169             command.add("-J".concat(flag));
170         }
171         final File outdir = new File(
172             this.env.tempdir(), String.format("errorprone-classes-%s", batch)
173         );
174         if (!outdir.exists() && !outdir.mkdirs()) {
175             throw new IllegalStateException(
176                 String.format("Unable to create %s", outdir)
177             );
178         }
179         final List<String> args = new ArrayList<>(sources.size() + 12);
180         args.add("-XDcompilePolicy=simple");
181         args.add("-XDaddTypeAnnotationsToSymbol=true");
182         args.add("--should-stop=ifError=FLOW");
183         args.add("-proc:none");
184         args.add("-Xlint:-options");
185         args.add("-encoding");
186         args.add(this.env.encoding().name());
187         args.addAll(new Release(this.env, batch).flags());
188         args.add(
189             new Xplugin(
190                 this.env.param(ErrorProneValidator.PARAM, "")
191             ).argument()
192         );
193         args.add("-processorpath");
194         args.add(ErrorProneValidator.pluginClasspath());
195         args.add("-d");
196         args.add(outdir.getAbsolutePath());
197         final Collection<String> classpath = this.env.classpath();
198         if (!classpath.isEmpty()) {
199             args.add("-classpath");
200             args.add(String.join(File.pathSeparator, classpath));
201         }
202         for (final File source : sources) {
203             args.add(source.getAbsolutePath());
204         }
205         command.add(
206             "@".concat(
207                 new Argfile(
208                     new File(
209                         this.env.tempdir(),
210                         String.format("errorprone-args-%s.txt", batch)
211                     ),
212                     args
213                 ).save().getAbsolutePath()
214             )
215         );
216         return command;
217     }
218 
219     private List<File> relevant(final Collection<File> files) {
220         final List<File> sources = new ArrayList<>(files.size());
221         for (final File file : files) {
222             final String name = new Relative(this.env.basedir(), file).path();
223             if (this.env.exclude("errorprone", name)) {
224                 continue;
225             }
226             if (new Ignored(name).yes()) {
227                 continue;
228             }
229             if (!name.endsWith(".java")) {
230                 continue;
231             }
232             sources.add(file);
233         }
234         return sources;
235     }
236 
237     private static String javac() {
238         return new File(
239             new File(System.getProperty("java.home"), "bin"),
240             "javac"
241         ).getAbsolutePath();
242     }
243 
244     private static String pluginClasspath() {
245         final Set<String> entries = new LinkedHashSet<>();
246         ClassLoader loader = Thread.currentThread().getContextClassLoader();
247         while (loader != null) {
248             if (loader instanceof URLClassLoader) {
249                 for (final URL url : ((URLClassLoader) loader).getURLs()) {
250                     new Unencoded(url).path().ifPresent(entries::add);
251                 }
252             }
253             loader = loader.getParent();
254         }
255         for (final String entry
256             : Splitter.on(File.pathSeparatorChar).split(
257                 System.getProperty("java.class.path", "")
258             )
259         ) {
260             if (!entry.isEmpty()) {
261                 entries.add(new File(entry).getAbsolutePath());
262             }
263         }
264         ErrorProneValidator.addCodeSource(entries, Inject.class);
265         return String.join(File.pathSeparator, entries);
266     }
267 
268     private static void addCodeSource(
269         final Set<String> entries, final Class<?> klass
270     ) {
271         final CodeSource source =
272             klass.getProtectionDomain().getCodeSource();
273         if (source != null && source.getLocation() != null) {
274             new Unencoded(source.getLocation()).path().ifPresent(entries::add);
275         }
276     }
277 }