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.jcabi.log.Logger;
8   import com.qulice.spi.Environment;
9   import com.qulice.spi.Relative;
10  import com.qulice.spi.ResourceValidator;
11  import com.qulice.spi.Violation;
12  import com.yegor256.Jaxec;
13  import com.yegor256.Result;
14  import java.io.File;
15  import java.net.URL;
16  import java.net.URLClassLoader;
17  import java.util.ArrayList;
18  import java.util.Collection;
19  import java.util.LinkedHashSet;
20  import java.util.LinkedList;
21  import java.util.List;
22  import java.util.Set;
23  import java.util.regex.Matcher;
24  import java.util.regex.Pattern;
25  
26  /**
27   * Validates source code with Google ErrorProne.
28   *
29   * <p>Runs the {@code javac} executable from the active JDK as a forked
30   * process, with ErrorProne wired in as a {@code -Xplugin:ErrorProne} so
31   * that every bug pattern fires while {@code javac} type-checks the
32   * project's Java sources. The {@code --add-exports} and {@code --add-opens}
33   * flags ErrorProne needs to reach internal {@code jdk.compiler} packages
34   * are passed to the forked JVM via {@code javac}'s {@code -J} prefix; the
35   * JVM hosting Maven and Qulice is unaffected, so consumers do not have to
36   * touch their own {@code .mvn/jvm.config} to use this validator.</p>
37   *
38   * <p>Diagnostics from the forked {@code javac} are parsed from the
39   * combined stdout/stderr stream. Only lines that match the standard
40   * compiler diagnostic format and whose message starts with the
41   * {@code [CheckName]} prefix ErrorProne always emits are converted into
42   * {@link Violation}s — plain compile errors caused by the project not
43   * being built yet are ignored. {@code -proc:none} is passed to keep
44   * regular annotation processors (Lombok, Hibernate-Validator, etc.) out
45   * of the ErrorProne pass.</p>
46   *
47   * @since 1.0
48   */
49  public final class ErrorProneValidator implements ResourceValidator {
50  
51      /**
52       * JVM module-access flags ErrorProne requires to reach internal
53       * {@code jdk.compiler} APIs. Forwarded to the embedded JVM via
54       * {@code javac}'s {@code -J} prefix.
55       */
56      private static final List<String> JVM_FLAGS = List.of(
57          "--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
58          "--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
59          "--add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
60          "--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
61          "--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
62          "--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED",
63          "--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED",
64          "--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED",
65          "--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
66          "--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
67          "--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
68          "--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED"
69      );
70  
71      /**
72       * Standard {@code javac} diagnostic format with an ErrorProne
73       * {@code [CheckName]} prefix on the message:
74       * {@code path:line: warning|error: [Name] body}.
75       */
76      private static final Pattern DIAGNOSTIC = Pattern.compile(
77          "^(.+?):(\\d+): (?:warning|error): \\[([A-Za-z][A-Za-z0-9_]*)] (.+)$"
78      );
79  
80      /**
81       * Splits a multi-line stdout block into individual lines, on any
82       * line terminator (\\n, \\r, \\r\\n, etc.).
83       */
84      private static final Pattern NEWLINE = Pattern.compile("\\R");
85  
86      /**
87       * Environment to use.
88       */
89      private final Environment env;
90  
91      /**
92       * Constructor.
93       * @param env Environment to use
94       */
95      public ErrorProneValidator(final Environment env) {
96          this.env = env;
97      }
98  
99      @Override
100     public Collection<Violation> validate(final Collection<File> files) {
101         final List<File> sources = this.relevant(files);
102         final Collection<Violation> violations = new LinkedList<>();
103         if (sources.isEmpty()) {
104             Logger.debug(
105                 this,
106                 "No files to check with ErrorProne, all %d are excluded",
107                 files.size()
108             );
109         } else {
110             Logger.debug(this, "ErrorProne processing %d files", sources.size());
111             violations.addAll(this.parse(this.run(sources)));
112             Logger.debug(this, "ErrorProne processed %d files", sources.size());
113         }
114         return violations;
115     }
116 
117     @Override
118     public String name() {
119         return "ErrorProne";
120     }
121 
122     /**
123      * Run the forked {@code javac} process with ErrorProne enabled.
124      * @param sources Java source files to feed
125      * @return Combined stdout/stderr of the process, line by line
126      */
127     private List<String> run(final List<File> sources) {
128         final Result result = new Jaxec(this.command(sources))
129             .withRedirect(true)
130             .withCheck(false)
131             .exec();
132         final String stdout = result.stdout();
133         final List<String> lines;
134         if (stdout.isEmpty()) {
135             lines = List.of();
136         } else {
137             lines = List.of(ErrorProneValidator.NEWLINE.split(stdout));
138         }
139         return lines;
140     }
141 
142     /**
143      * Build the {@code javac} command line. To stay below the
144      * Windows {@code CreateProcess} 32 KB command-line limit even on
145      * projects with thousands of sources or long classpaths, every
146      * argument other than the launcher itself and the {@code -J}
147      * flags (which {@code javac} forbids inside argfiles) is written
148      * to a temporary argfile and passed as {@code @argfile}.
149      * @param sources Java source files to feed
150      * @return Argv
151      */
152     private List<String> command(final List<File> sources) {
153         final List<String> command = new ArrayList<>(
154             ErrorProneValidator.JVM_FLAGS.size() + 2
155         );
156         command.add(ErrorProneValidator.javac());
157         for (final String flag : ErrorProneValidator.JVM_FLAGS) {
158             command.add("-J".concat(flag));
159         }
160         final File outdir = new File(this.env.tempdir(), "errorprone-classes");
161         if (!outdir.exists() && !outdir.mkdirs()) {
162             throw new IllegalStateException(
163                 String.format("Unable to create %s", outdir)
164             );
165         }
166         final List<String> args = new ArrayList<>(sources.size() + 11);
167         args.add("-XDcompilePolicy=simple");
168         args.add("-XDaddTypeAnnotationsToSymbol=true");
169         args.add("--should-stop=ifError=FLOW");
170         args.add("-proc:none");
171         args.add("-Xplugin:ErrorProne -Xep:InvalidBlockTag:OFF");
172         args.add("-processorpath");
173         args.add(ErrorProneValidator.pluginClasspath());
174         args.add("-d");
175         args.add(outdir.getAbsolutePath());
176         final Collection<String> classpath = this.env.classpath();
177         if (!classpath.isEmpty()) {
178             args.add("-classpath");
179             args.add(String.join(File.pathSeparator, classpath));
180         }
181         for (final File source : sources) {
182             args.add(source.getAbsolutePath());
183         }
184         command.add(
185             "@".concat(
186                 new Argfile(
187                     new File(this.env.tempdir(), "errorprone-args.txt"), args
188                 ).save().getAbsolutePath()
189             )
190         );
191         return command;
192     }
193 
194     /**
195      * Translate diagnostic lines into Qulice violations, keeping only
196      * those messages prefixed by an ErrorProne bug-pattern name.
197      * @param output Combined stdout/stderr of the forked process
198      * @return Violations
199      */
200     private Collection<Violation> parse(final List<String> output) {
201         final Collection<Violation> violations = new LinkedList<>();
202         for (final String line : output) {
203             final Matcher matcher = ErrorProneValidator.DIAGNOSTIC.matcher(line);
204             if (matcher.matches()) {
205                 final String check = matcher.group(3);
206                 violations.add(
207                     new Violation.Default(
208                         this.name(),
209                         check,
210                         matcher.group(1),
211                         matcher.group(2),
212                         String.format("[%s] %s", check, matcher.group(4))
213                     )
214                 );
215             }
216         }
217         return violations;
218     }
219 
220     /**
221      * Filters out non-Java and excluded files from further validation.
222      * @param files Files to validate
223      * @return List of relevant files
224      */
225     private List<File> relevant(final Collection<File> files) {
226         final List<File> sources = new LinkedList<>();
227         for (final File file : files) {
228             final String name = new Relative(this.env.basedir(), file).path();
229             if (this.env.exclude("errorprone", name)) {
230                 continue;
231             }
232             if (!name.endsWith(".java")) {
233                 continue;
234             }
235             sources.add(file);
236         }
237         return sources;
238     }
239 
240     /**
241      * Resolve the {@code javac} executable from the running JDK.
242      * @return Absolute path to {@code ${java.home}/bin/javac}
243      */
244     private static String javac() {
245         return new File(
246             new File(System.getProperty("java.home"), "bin"),
247             "javac"
248         ).getAbsolutePath();
249     }
250 
251     /**
252      * Build the {@code -processorpath} value passed to the forked
253      * {@code javac}. Combines two sources: every URL on the
254      * {@link URLClassLoader} chain starting from the thread context
255      * classloader (the qulice plugin's own {@code ClassRealm} plus its
256      * URL-based parents, which carries ErrorProne when this code runs
257      * inside a Maven plugin execution), and the jar locations of a few
258      * classes ErrorProne needs at runtime that Maven's classworlds
259      * imports from a non-URL parent realm (notably
260      * {@link javax.inject.Inject}, which the
261      * {@code ErrorProneInjector} reads to find injectable constructors).
262      * Both are required: without the realm URLs there's no
263      * {@code error_prone_core}, without the protection-domain lookup
264      * there's no {@code javax.inject}.
265      * @return Path-separator joined list of jar paths
266      */
267     private static String pluginClasspath() {
268         final Set<String> entries = new LinkedHashSet<>();
269         ClassLoader loader = Thread.currentThread().getContextClassLoader();
270         while (loader != null) {
271             if (loader instanceof URLClassLoader) {
272                 for (final URL url : ((URLClassLoader) loader).getURLs()) {
273                     entries.add(new File(url.getPath()).getAbsolutePath());
274                 }
275             }
276             loader = loader.getParent();
277         }
278         for (final String entry
279             : System.getProperty("java.class.path", "").split(File.pathSeparator)) {
280             if (!entry.isEmpty()) {
281                 entries.add(new File(entry).getAbsolutePath());
282             }
283         }
284         ErrorProneValidator.addCodeSource(entries, javax.inject.Inject.class);
285         return String.join(File.pathSeparator, entries);
286     }
287 
288     /**
289      * Append the jar that contains the given class to {@code entries},
290      * resolved via {@link Class#getProtectionDomain()}. Silently ignored
291      * if the class is loaded from a non-file location (e.g. a JRT module
292      * or an exploded directory).
293      * @param entries Set to append to
294      * @param klass Class whose code source jar should be included
295      */
296     private static void addCodeSource(
297         final Set<String> entries, final Class<?> klass
298     ) {
299         final java.security.CodeSource source =
300             klass.getProtectionDomain().getCodeSource();
301         if (source != null && source.getLocation() != null) {
302             try {
303                 entries.add(
304                     new File(source.getLocation().toURI()).getAbsolutePath()
305                 );
306             } catch (final java.net.URISyntaxException | IllegalArgumentException ex) {
307                 Logger.debug(
308                     ErrorProneValidator.class,
309                     "Cannot resolve code source for %s: %s", klass, ex.getMessage()
310                 );
311             }
312         }
313     }
314 }