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.puppycrawl.tools.checkstyle.Checker;
8   import com.puppycrawl.tools.checkstyle.ConfigurationLoader;
9   import com.puppycrawl.tools.checkstyle.DefaultConfiguration;
10  import com.puppycrawl.tools.checkstyle.PropertiesExpander;
11  import com.puppycrawl.tools.checkstyle.api.AuditEvent;
12  import com.puppycrawl.tools.checkstyle.api.CheckstyleException;
13  import com.puppycrawl.tools.checkstyle.api.Configuration;
14  import com.qulice.spi.Environment;
15  import com.qulice.spi.Relative;
16  import com.qulice.spi.Violation;
17  import java.io.File;
18  import java.io.IOException;
19  import java.io.InputStream;
20  import java.nio.file.Files;
21  import java.util.ArrayList;
22  import java.util.Collection;
23  import java.util.Properties;
24  import java.util.Set;
25  import org.xml.sax.InputSource;
26  
27  /**
28   * The {@code @checkstyle} suppressions that cover no violation.
29   *
30   * <p>Checkstyle honors a comment like {@code @checkstyle TypeName (N
31   * lines)} through its suppression filters but reports nothing when the
32   * suppressed lines hold no violation of {@code TypeName}, so the comment
33   * lingers as a lie about the code. {@link UnknownSuppressionCheck} already
34   * rejects a name that no enabled check answers to; this class covers the
35   * other half, the way PMD does with {@code UnnecessaryWarningSuppression},
36   * by running Checkstyle a second time with the nearby filters removed and
37   * reporting each suppression whose range holds no event of its check.</p>
38   *
39   * <p>The second run leaves alone the files that the first run took from
40   * its cache, since Checkstyle collects no events for them, and their
41   * suppressions cannot be judged.</p>
42   *
43   * @since 1.0
44   */
45  final class UnusedSuppressions {
46  
47      /**
48       * Filters of {@code checks.xml} that honor the {@code @checkstyle}
49       * comments. Dropping them lets the second run see every violation the
50       * comments mean to hide.
51       */
52      private static final Set<String> FILTERS = Set.of(
53          "SuppressWithNearbyCommentFilter",
54          "SuppressWithNearbyTextFilter",
55          "SuppressWithPlainTextCommentFilter"
56      );
57  
58      /**
59       * Environment to use.
60       */
61      private final Environment env;
62  
63      /**
64       * Constructor.
65       *
66       * @param env Environment to use
67       */
68      UnusedSuppressions(final Environment env) {
69          this.env = env;
70      }
71  
72      /**
73       * Find the suppressions that cover no violation.
74       *
75       * @param files Files the primary run processed, cache aside
76       * @return Violations, one per dead suppression
77       */
78      Collection<Violation> validate(final Collection<File> files) {
79          final Collection<Violation> results = new ArrayList<>(0);
80          if (!files.isEmpty()) {
81              final CheckstyleListener sink = new CheckstyleListener(this.env);
82              this.collect(files, sink);
83              for (final File file : files) {
84                  results.addAll(this.unused(file, sink.events()));
85              }
86          }
87          return results;
88      }
89  
90      private void collect(final Collection<File> files,
91          final CheckstyleListener sink) {
92          final Checker checker = new Checker();
93          checker.setModuleClassLoader(
94              Thread.currentThread().getContextClassLoader()
95          );
96          final File cache = this.cache();
97          try {
98              checker.configure(this.configuration(cache));
99              checker.addListener(sink);
100             checker.process(new ArrayList<>(files));
101         } catch (final CheckstyleException ex) {
102             throw new IllegalStateException(
103                 "Failed to re-run Checkstyle without suppression filters", ex
104             );
105         } finally {
106             checker.destroy();
107             cache.delete();
108         }
109     }
110 
111     private Collection<Violation> unused(final File file,
112         final Collection<AuditEvent> events) {
113         final Collection<Violation> results = new ArrayList<>(0);
114         if (!this.env.exclude(
115             "checkstyle", new Relative(this.env.basedir(), file).path()
116         )) {
117             final Collection<AuditEvent> here = new ArrayList<>(0);
118             for (final AuditEvent event : events) {
119                 if (file.getPath().equals(event.getFileName())) {
120                     here.add(event);
121                 }
122             }
123             for (final SuppressionTag tag : new Suppressions(this.read(file))) {
124                 if (tag.unused(here)) {
125                     results.add(
126                         new Violation.Default(
127                             "Checkstyle",
128                             "UnusedSuppressionCheck",
129                             file.getPath(),
130                             String.valueOf(tag.line()),
131                             String.format(
132                                 "This suppression covers no violation of \"%s\"",
133                                 tag.check()
134                             )
135                         )
136                     );
137                 }
138             }
139         }
140         return results;
141     }
142 
143     private String read(final File file) {
144         try {
145             return Files.readString(file.toPath(), this.env.encoding());
146         } catch (final IOException ex) {
147             throw new IllegalStateException(
148                 String.format("Failed to read %s", file), ex
149             );
150         }
151     }
152 
153     private File cache() {
154         final File dir = new File(this.env.tempdir(), "checkstyle");
155         if (!dir.exists() && !dir.mkdirs()) {
156             throw new IllegalStateException(
157                 String.format("Unable to create %s", dir)
158             );
159         }
160         try {
161             return Files.createTempFile(dir.toPath(), "unused", ".cache").toFile();
162         } catch (final IOException ex) {
163             throw new IllegalStateException(
164                 "Failed to create a cache file for the second Checkstyle run", ex
165             );
166         }
167     }
168 
169     private Configuration configuration(final File cache) {
170         final Properties props = new Properties();
171         props.setProperty("cache.file", cache.getPath());
172         final Configuration config;
173         try (InputStream stream = this.getClass().getResourceAsStream("checks.xml")) {
174             if (stream == null) {
175                 throw new IllegalStateException(
176                     "Checkstyle configuration file 'checks.xml' not found in classpath."
177                 );
178             }
179             config = ConfigurationLoader.loadConfiguration(
180                 new InputSource(stream),
181                 new PropertiesExpander(props),
182                 ConfigurationLoader.IgnoredModulesOptions.OMIT
183             );
184         } catch (final CheckstyleException | IOException ex) {
185             throw new IllegalStateException("Failed to load config", ex);
186         }
187         UnusedSuppressions.strip(config);
188         return config;
189     }
190 
191     private static void strip(final Configuration config) {
192         for (final Configuration child : config.getChildren()) {
193             final String name = child.getName();
194             final String simple = name.substring(name.lastIndexOf('.') + 1);
195             if (UnusedSuppressions.FILTERS.contains(simple)) {
196                 ((DefaultConfiguration) config).removeChild(child);
197             } else {
198                 UnusedSuppressions.strip(child);
199             }
200         }
201     }
202 }