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.
144      * @param sources Java source files to feed
145      * @return Argv
146      */
147     private List<String> command(final List<File> sources) {
148         final List<String> command = new ArrayList<>(
149             sources.size() + ErrorProneValidator.JVM_FLAGS.size() + 11
150         );
151         command.add(ErrorProneValidator.javac());
152         for (final String flag : ErrorProneValidator.JVM_FLAGS) {
153             command.add("-J".concat(flag));
154         }
155         command.add("-XDcompilePolicy=simple");
156         command.add("-XDaddTypeAnnotationsToSymbol=true");
157         command.add("--should-stop=ifError=FLOW");
158         command.add("-proc:none");
159         command.add("-Xplugin:ErrorProne");
160         command.add("-processorpath");
161         command.add(ErrorProneValidator.pluginClasspath());
162         final File outdir = new File(this.env.tempdir(), "errorprone-classes");
163         if (!outdir.exists() && !outdir.mkdirs()) {
164             throw new IllegalStateException(
165                 String.format("Unable to create %s", outdir)
166             );
167         }
168         command.add("-d");
169         command.add(outdir.getAbsolutePath());
170         final Collection<String> classpath = this.env.classpath();
171         if (!classpath.isEmpty()) {
172             command.add("-classpath");
173             command.add(String.join(File.pathSeparator, classpath));
174         }
175         for (final File source : sources) {
176             command.add(source.getAbsolutePath());
177         }
178         return command;
179     }
180 
181     /**
182      * Translate diagnostic lines into Qulice violations, keeping only
183      * those messages prefixed by an ErrorProne bug-pattern name.
184      * @param output Combined stdout/stderr of the forked process
185      * @return Violations
186      */
187     private Collection<Violation> parse(final List<String> output) {
188         final Collection<Violation> violations = new LinkedList<>();
189         for (final String line : output) {
190             final Matcher matcher = ErrorProneValidator.DIAGNOSTIC.matcher(line);
191             if (matcher.matches()) {
192                 final String check = matcher.group(3);
193                 violations.add(
194                     new Violation.Default(
195                         this.name(),
196                         check,
197                         matcher.group(1),
198                         matcher.group(2),
199                         String.format("[%s] %s", check, matcher.group(4))
200                     )
201                 );
202             }
203         }
204         return violations;
205     }
206 
207     /**
208      * Filters out non-Java and excluded files from further validation.
209      * @param files Files to validate
210      * @return List of relevant files
211      */
212     private List<File> relevant(final Collection<File> files) {
213         final List<File> sources = new LinkedList<>();
214         for (final File file : files) {
215             final String name = new Relative(this.env.basedir(), file).path();
216             if (this.env.exclude("errorprone", name)) {
217                 continue;
218             }
219             if (!name.endsWith(".java")) {
220                 continue;
221             }
222             sources.add(file);
223         }
224         return sources;
225     }
226 
227     /**
228      * Resolve the {@code javac} executable from the running JDK.
229      * @return Absolute path to {@code ${java.home}/bin/javac}
230      */
231     private static String javac() {
232         return new File(
233             new File(System.getProperty("java.home"), "bin"),
234             "javac"
235         ).getAbsolutePath();
236     }
237 
238     /**
239      * Build the {@code -processorpath} value passed to the forked
240      * {@code javac}. Combines two sources: every URL on the
241      * {@link URLClassLoader} chain starting from the thread context
242      * classloader (the qulice plugin's own {@code ClassRealm} plus its
243      * URL-based parents, which carries ErrorProne when this code runs
244      * inside a Maven plugin execution), and the jar locations of a few
245      * classes ErrorProne needs at runtime that Maven's classworlds
246      * imports from a non-URL parent realm (notably
247      * {@link javax.inject.Inject}, which the
248      * {@code ErrorProneInjector} reads to find injectable constructors).
249      * Both are required: without the realm URLs there's no
250      * {@code error_prone_core}, without the protection-domain lookup
251      * there's no {@code javax.inject}.
252      * @return Path-separator joined list of jar paths
253      */
254     private static String pluginClasspath() {
255         final Set<String> entries = new LinkedHashSet<>();
256         ClassLoader loader = Thread.currentThread().getContextClassLoader();
257         while (loader != null) {
258             if (loader instanceof URLClassLoader) {
259                 for (final URL url : ((URLClassLoader) loader).getURLs()) {
260                     entries.add(new File(url.getPath()).getAbsolutePath());
261                 }
262             }
263             loader = loader.getParent();
264         }
265         for (final String entry
266             : System.getProperty("java.class.path", "").split(File.pathSeparator)) {
267             if (!entry.isEmpty()) {
268                 entries.add(new File(entry).getAbsolutePath());
269             }
270         }
271         ErrorProneValidator.addCodeSource(entries, javax.inject.Inject.class);
272         return String.join(File.pathSeparator, entries);
273     }
274 
275     /**
276      * Append the jar that contains the given class to {@code entries},
277      * resolved via {@link Class#getProtectionDomain()}. Silently ignored
278      * if the class is loaded from a non-file location (e.g. a JRT module
279      * or an exploded directory).
280      * @param entries Set to append to
281      * @param klass Class whose code source jar should be included
282      */
283     private static void addCodeSource(
284         final Set<String> entries, final Class<?> klass
285     ) {
286         final java.security.CodeSource source =
287             klass.getProtectionDomain().getCodeSource();
288         if (source != null && source.getLocation() != null) {
289             try {
290                 entries.add(
291                     new File(source.getLocation().toURI()).getAbsolutePath()
292                 );
293             } catch (final java.net.URISyntaxException | IllegalArgumentException ex) {
294                 Logger.debug(
295                     ErrorProneValidator.class,
296                     "Cannot resolve code source for %s: %s", klass, ex.getMessage()
297                 );
298             }
299         }
300     }
301 }