View Javadoc
1   /*
2    * SPDX-FileCopyrightText: Copyright (c) 2011-2026 Yegor Bugayenko
3    * SPDX-License-Identifier: MIT
4    */
5   package com.qulice.checkstyle;
6   
7   import com.jcabi.log.Logger;
8   import com.puppycrawl.tools.checkstyle.Checker;
9   import com.puppycrawl.tools.checkstyle.ConfigurationLoader;
10  import com.puppycrawl.tools.checkstyle.DefaultConfiguration;
11  import com.puppycrawl.tools.checkstyle.PropertiesExpander;
12  import com.puppycrawl.tools.checkstyle.api.AuditEvent;
13  import com.puppycrawl.tools.checkstyle.api.CheckstyleException;
14  import com.puppycrawl.tools.checkstyle.api.Configuration;
15  import com.qulice.spi.Environment;
16  import com.qulice.spi.Ignored;
17  import com.qulice.spi.Relative;
18  import com.qulice.spi.ResourceValidator;
19  import com.qulice.spi.Violation;
20  import java.io.File;
21  import java.io.IOException;
22  import java.io.InputStream;
23  import java.util.ArrayList;
24  import java.util.Collection;
25  import java.util.List;
26  import java.util.Locale;
27  import java.util.Properties;
28  import java.util.Set;
29  import org.xml.sax.InputSource;
30  
31  /**
32   * Validator with Checkstyle.
33   *
34   * @since 0.3
35   */
36  public final class CheckstyleValidator implements ResourceValidator {
37  
38      /**
39       * Extensions of files that are passed to Checkstyle. These match the
40       * file extensions referenced by checks in {@code checks.xml}. Checkstyle
41       * itself filters further based on each module's {@code fileExtensions}.
42       */
43      private static final Set<String> EXTENSIONS = Set.of(
44          "java", "txt", "xml", "xsl", "xsd", "properties", "groovy", "vm",
45          "mf", "sh", "sql", "tokens", "g", "spec", "css", "csv", "js", "json",
46          "md", "yml", "yaml", "gradle", "dtd", "scss", "html"
47      );
48  
49      /**
50       * Checks that suggest fixes relying on Java language features not
51       * available under {@code -source 8}/{@code -target 8} (and other
52       * pre-14 levels). Such a check must be stripped from the configuration
53       * when the project targets an older Java, otherwise it recommends code
54       * that fails to compile. {@code UseEnhancedSwitch} suggests arrow-switch
55       * syntax, which is a Java 14+ feature (see #1694).
56       */
57      private static final Set<String> MODERN = Set.of("UseEnhancedSwitch");
58  
59      /**
60       * The minimum Java source level at which the {@link #MODERN} checks are
61       * applicable. Below this, they are removed from the configuration.
62       */
63      private static final int MINIMUM = 14;
64  
65      /**
66       * Checkstyle checker.
67       */
68      private final Checker checker;
69  
70      /**
71       * Listener of checkstyle messages.
72       */
73      private final CheckstyleListener listener;
74  
75      /**
76       * Environment to use.
77       */
78      private final Environment env;
79  
80      /**
81       * Constructor.
82       *
83       * @param env Environment to use
84       */
85      public CheckstyleValidator(final Environment env) {
86          this.env = env;
87          this.checker = new Checker();
88          this.listener = new CheckstyleListener(this.env);
89      }
90  
91      @Override
92      public Collection<Violation> validate(final Collection<File> files) {
93          this.checker.setModuleClassLoader(
94              Thread.currentThread().getContextClassLoader()
95          );
96          try {
97              this.checker.configure(this.configuration());
98          } catch (final CheckstyleException ex) {
99              throw new IllegalStateException("Failed to configure checker", ex);
100         }
101         this.checker.addListener(this.listener);
102         final List<File> sources = this.getNonExcludedFiles(files);
103         final Collection<Violation> results = new ArrayList<>(0);
104         if (sources.isEmpty()) {
105             Logger.debug(
106                 this,
107                 "No files to check with Checkstyle, all %d are excluded",
108                 files.size()
109             );
110         } else {
111             try {
112                 Logger.debug(this, "Checkstyle processing %d files", sources.size());
113                 this.checker.process(sources);
114                 Logger.debug(this, "Checkstyle processed %d files", sources.size());
115             } catch (final CheckstyleException ex) {
116                 throw new IllegalStateException("Failed to process files", ex);
117             }
118             for (final AuditEvent event : this.listener.events()) {
119                 final String check = event.getSourceName();
120                 results.add(
121                     new Violation.Default(
122                         this.name(),
123                         check.substring(check.lastIndexOf('.') + 1),
124                         event.getFileName(),
125                         String.valueOf(event.getLine()),
126                         event.getMessage()
127                     )
128                 );
129             }
130             results.addAll(
131                 new UnusedSuppressions(this.env).validate(this.listener.processed())
132             );
133         }
134         return results;
135     }
136 
137     @Override public String name() {
138         return "Checkstyle";
139     }
140 
141     @Override
142     public int rules() {
143         return CheckstyleValidator.count(this.configuration());
144     }
145 
146     /**
147      * Filters out excluded files from further validation.
148      *
149      * @param files Files to validate
150      * @return List of relevant files
151      */
152     public List<File> getNonExcludedFiles(final Collection<File> files) {
153         final List<File> relevant = new ArrayList<>(files.size());
154         for (final File file : files) {
155             final String name = new Relative(this.env.basedir(), file).path();
156             if (this.env.exclude("checkstyle", name)) {
157                 continue;
158             }
159             if (new Ignored(name).yes()) {
160                 continue;
161             }
162             final int dot = name.lastIndexOf('.');
163             if (dot < 0) {
164                 continue;
165             }
166             final String ext = name.substring(dot + 1).toLowerCase(Locale.ROOT);
167             if (!CheckstyleValidator.EXTENSIONS.contains(ext)) {
168                 continue;
169             }
170             relevant.add(file);
171         }
172         return relevant;
173     }
174 
175     private Configuration configuration() {
176         final File cache =
177             new File(this.env.tempdir(), "checkstyle/checkstyle.cache");
178         final File parent = cache.getParentFile();
179         if (!parent.exists() && !parent.mkdirs()) {
180             throw new IllegalStateException(
181                 String.format(
182                     "Unable to create directories needed for %s",
183                     cache.getPath()
184                 )
185             );
186         }
187         if (!parent.canWrite()) {
188             throw new IllegalStateException(
189                 String.format(
190                     "Cannot write to %s, check filesystem permissions",
191                     parent.getAbsolutePath()
192                 )
193             );
194         }
195         final Properties props = new Properties();
196         props.setProperty("cache.file", cache.getPath());
197         final Configuration config;
198         try (InputStream stream = this.getClass().getResourceAsStream("checks.xml")) {
199             if (stream == null) {
200                 throw new IllegalStateException(
201                     "Checkstyle configuration file 'checks.xml' not found in classpath."
202                 );
203             }
204             config = ConfigurationLoader.loadConfiguration(
205                 new InputSource(stream),
206                 new PropertiesExpander(props),
207                 ConfigurationLoader.IgnoredModulesOptions.OMIT
208             );
209         } catch (final CheckstyleException | IOException ex) {
210             throw new IllegalStateException("Failed to load config", ex);
211         }
212         if (this.level() < CheckstyleValidator.MINIMUM) {
213             CheckstyleValidator.strip(config, CheckstyleValidator.MODERN);
214         }
215         return config;
216     }
217 
218     private int level() {
219         int level = CheckstyleValidator.parse(
220             this.env.param("maven.compiler.release", "")
221         );
222         if (level < 0) {
223             level = CheckstyleValidator.parse(
224                 this.env.param("maven.compiler.source", "")
225             );
226         }
227         final int result;
228         if (level < 0) {
229             result = CheckstyleValidator.MINIMUM - 1;
230         } else {
231             result = level;
232         }
233         return result;
234     }
235 
236     private static int parse(final String value) {
237         int result = -1;
238         if (value != null) {
239             String txt = value.trim();
240             if (txt.startsWith("1.")) {
241                 txt = txt.substring(2);
242             }
243             try {
244                 result = Integer.parseInt(txt);
245             } catch (final NumberFormatException ex) {
246                 result = -1;
247             }
248         }
249         return result;
250     }
251 
252     private static int count(final Configuration config) {
253         int total = 0;
254         for (final Configuration child : config.getChildren()) {
255             final String name = child.getName();
256             if (child.getChildren().length > 0) {
257                 total += CheckstyleValidator.count(child);
258             } else if (!name.endsWith("Filter") && !name.endsWith("Holder")) {
259                 total += 1;
260             }
261         }
262         return total;
263     }
264 
265     private static void strip(final Configuration config,
266         final Set<String> names) {
267         for (final Configuration child : config.getChildren()) {
268             final String name = child.getName();
269             final String simple = name.substring(name.lastIndexOf('.') + 1);
270             if (names.contains(simple)) {
271                 ((DefaultConfiguration) config).removeChild(child);
272             } else {
273                 CheckstyleValidator.strip(child, names);
274             }
275         }
276     }
277 }