View Javadoc
1   ///////////////////////////////////////////////////////////////////////////////////////////////
2   // checkstyle: Checks Java source code and other text files for adherence to a set of rules.
3   // Copyright (C) 2001-2026 the original author or authors.
4   //
5   // This library is free software; you can redistribute it and/or
6   // modify it under the terms of the GNU Lesser General Public
7   // License as published by the Free Software Foundation; either
8   // version 2.1 of the License, or (at your option) any later version.
9   //
10  // This library is distributed in the hope that it will be useful,
11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13  // Lesser General Public License for more details.
14  //
15  // You should have received a copy of the GNU Lesser General Public
16  // License along with this library; if not, write to the Free Software
17  // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
18  ///////////////////////////////////////////////////////////////////////////////////////////////
19  
20  package com.puppycrawl.tools.checkstyle.checks.coding;
21  
22  import java.util.ArrayDeque;
23  import java.util.ArrayList;
24  import java.util.Deque;
25  import java.util.HashMap;
26  import java.util.HashSet;
27  import java.util.List;
28  import java.util.Map;
29  import java.util.Set;
30  import java.util.regex.Pattern;
31  import java.util.stream.Collectors;
32  
33  import com.puppycrawl.tools.checkstyle.FileStatefulCheck;
34  import com.puppycrawl.tools.checkstyle.api.AbstractCheck;
35  import com.puppycrawl.tools.checkstyle.api.DetailAST;
36  import com.puppycrawl.tools.checkstyle.api.FullIdent;
37  import com.puppycrawl.tools.checkstyle.api.TokenTypes;
38  import com.puppycrawl.tools.checkstyle.utils.AnnotationUtil;
39  import com.puppycrawl.tools.checkstyle.utils.CommonUtil;
40  
41  /**
42   * <div>
43   * Check that a private field is declared, but never used. Fields with any other
44   * visibility (package-private, protected, or public), including those declared in
45   * an implicitly declared class (compact source file), are not checked.
46   * </div>
47   *
48   * @since 14.1.0
49   */
50  @FileStatefulCheck
51  public class UnusedPrivateFieldCheck extends AbstractCheck {
52  
53      /**
54       * A key is pointing to the warning message text in "messages.properties".
55       */
56      public static final String MSG_PRIVATE_FIELD = "unused.private.field";
57  
58      /**
59       * Stack of private field maps, one per class nesting level.
60       */
61      private final Deque<Map<String, DetailAST>> privateFields = new ArrayDeque<>();
62  
63      /**
64       * Stack of currently open enclosing type names.
65       */
66      private final Deque<String> enclosingTypeNames = new ArrayDeque<>();
67  
68      /**
69       * Recorded field-name usage occurrences.
70       */
71      private final List<FieldUsage> fieldUsages = new ArrayList<>();
72  
73      /**
74       * Global set of field names accessed.
75       */
76      private final Set<String> globalUsedFields = new HashSet<>();
77  
78      /**
79       * Accumulated pending fields, reported at finishTree.
80       */
81      private final List<PendingField> pendingFields = new ArrayList<>();
82  
83      /**
84       * Scope stack tracking local variable and parameter names per block.
85       */
86      private final Deque<Map<String, String>> scopeStack = new ArrayDeque<>();
87  
88      /**
89       * Snapshots of scope stack saved when entering a nested class,
90       * restored when leaving it.
91       */
92      private final Deque<Deque<Map<String, String>>> scopeStackSnapshots = new ArrayDeque<>();
93  
94      /**
95       * Recorded field-name accesses on a qualifier.
96       */
97      private final List<TypedUsage> typedUsages = new ArrayList<>();
98  
99      /**
100      * Each class's private fields, keyed by simple type name, populated once
101      * a class's OBJBLOCK closes. Used to resolve {@link #typedUsages}.
102      */
103     private final Map<String, Map<String, DetailAST>> privateFieldsByType = new HashMap<>();
104 
105     /**
106      * Specify annotations canonical names which ignore variables in consideration.
107      * A field is ignored either when the field itself carries a matching annotation,
108      * or when its enclosing class, interface, enum, or record carries one (e.g. a
109      * class-level Lombok {@code @Getter}).
110      */
111     private Set<String> ignoreAnnotationCanonicalNames = new HashSet<>(Set.of("java.io.Serial"));
112 
113     /**
114      * Specify a regular expression pattern for field names to ignore.
115      */
116     private Pattern ignoredFieldPattern = Pattern.compile("serialVersionUID");
117 
118     /**
119      * Set of ignore annotations short names.
120      */
121     private Set<String> ignoreAnnotationShortNames = new HashSet<>();
122 
123     /**
124      * Creates a new {@code UnusedPrivateFieldCheck} instance with default values.
125      *
126      */
127     public UnusedPrivateFieldCheck() {
128         // default constructor
129     }
130 
131     /**
132      * Setter to specify annotations canonical names which ignore variables in consideration.
133      *
134      * @param annotationNames array of ignore annotations canonical names.
135      * @since 14.1.0
136      */
137     public void setIgnoreAnnotationCanonicalNames(String... annotationNames) {
138         ignoreAnnotationCanonicalNames = Set.of(annotationNames);
139     }
140 
141     /**
142      * Setter to specify a regular expression pattern for field names to ignore, even
143      * if they otherwise satisfy this check's detection of an unused private field.
144      * Note this replaces the default value entirely — to keep {@code serialVersionUID}
145      * ignored alongside your own pattern, include it explicitly, e.g.
146      * {@code ^(serialVersionUID|LOG|LOGGER)$}.
147      *
148      * @param pattern regular expression pattern for field names to ignore.
149      * @since 14.1.0
150      */
151     public void setIgnoredFieldPattern(Pattern pattern) {
152         ignoredFieldPattern = pattern;
153     }
154 
155     @Override
156     public int[] getAcceptableTokens() {
157         return new int[] {
158             TokenTypes.IMPORT,
159             TokenTypes.OBJBLOCK,
160             TokenTypes.VARIABLE_DEF,
161             TokenTypes.PARAMETER_DEF,
162             TokenTypes.PARAMETERS,
163             TokenTypes.SLIST,
164             TokenTypes.IDENT,
165             TokenTypes.METHOD_DEF,
166             TokenTypes.CTOR_DEF,
167             TokenTypes.LAMBDA,
168             TokenTypes.LITERAL_FOR,
169             TokenTypes.LITERAL_CATCH,
170         };
171     }
172 
173     @Override
174     public int[] getDefaultTokens() {
175         return getAcceptableTokens();
176     }
177 
178     @Override
179     public int[] getRequiredTokens() {
180         return getAcceptableTokens();
181     }
182 
183     @Override
184     public void beginTree(DetailAST rootAST) {
185         privateFields.clear();
186         enclosingTypeNames.clear();
187         fieldUsages.clear();
188         globalUsedFields.clear();
189         pendingFields.clear();
190         scopeStack.clear();
191         scopeStackSnapshots.clear();
192         typedUsages.clear();
193         privateFieldsByType.clear();
194         ignoreAnnotationShortNames = ignoreAnnotationCanonicalNames.stream()
195                 .map(CommonUtil::baseClassName)
196                 .collect(Collectors.toCollection(HashSet::new));
197     }
198 
199     @Override
200     public void visitToken(DetailAST ast) {
201         switch (ast.getType()) {
202             case TokenTypes.OBJBLOCK -> {
203                 privateFields.push(new HashMap<>());
204                 scopeStackSnapshots.push(new ArrayDeque<>(scopeStack));
205                 scopeStack.clear();
206                 final DetailAST typeDef = ast.getParent();
207                 final DetailAST nameIdent = typeDef.findFirstToken(TokenTypes.IDENT);
208                 if (nameIdent == null) {
209                     enclosingTypeNames.push("");
210                 }
211                 else {
212                     enclosingTypeNames.push(nameIdent.getText());
213                 }
214             }
215             case TokenTypes.PARAMETERS, TokenTypes.SLIST, TokenTypes.LITERAL_FOR,
216                  TokenTypes.LITERAL_CATCH -> scopeStack.push(new HashMap<>());
217             case TokenTypes.PARAMETER_DEF -> {
218                 final DetailAST ident = ast.findFirstToken(TokenTypes.IDENT);
219                 if (ident != null) {
220                     scopeStack.peek().put(ident.getText(), resolveDeclaredTypeName(ast));
221                 }
222             }
223             case TokenTypes.VARIABLE_DEF -> handleVariableDef(ast);
224             case TokenTypes.IDENT -> handleIdent(ast);
225             default -> {
226                 // no action needed for other token types
227             }
228         }
229     }
230 
231     @Override
232     public void leaveToken(DetailAST ast) {
233         switch (ast.getType()) {
234             case TokenTypes.OBJBLOCK -> {
235                 final Map<String, DetailAST> classFields = privateFields.pop();
236                 privateFieldsByType.put(enclosingTypeNames.peek(), classFields);
237                 for (final Map.Entry<String, DetailAST> entry : classFields.entrySet()) {
238                     pendingFields.add(new PendingField(entry));
239                 }
240                 final Deque<Map<String, String>> snapshot = scopeStackSnapshots.pop();
241                 snapshot.forEach(scopeStack::push);
242                 enclosingTypeNames.pop();
243             }
244             case TokenTypes.LAMBDA -> {
245                 if (ast.findFirstToken(TokenTypes.PARAMETERS) != null) {
246                     scopeStack.pop();
247                 }
248             }
249             case TokenTypes.METHOD_DEF, TokenTypes.CTOR_DEF,
250                  TokenTypes.SLIST, TokenTypes.LITERAL_FOR,
251                  TokenTypes.LITERAL_CATCH -> scopeStack.pop();
252             default -> {
253                 // no action needed for other token types
254             }
255         }
256     }
257 
258     @Override
259     public void finishTree(final DetailAST rootAST) {
260         final Set<DetailAST> usedFieldIdents = new HashSet<>();
261         fieldUsages.stream()
262                 .map(UnusedPrivateFieldCheck::resolveUsage)
263                 .forEach(usedFieldIdents::add);
264         for (final TypedUsage typedUsage : typedUsages) {
265             final Map<String, DetailAST> fields = privateFieldsByType.get(typedUsage.typeName());
266             if (fields != null) {
267                 final DetailAST ident = fields.get(typedUsage.fieldName());
268                 usedFieldIdents.add(ident);
269 
270             }
271         }
272         for (final PendingField pending : pendingFields) {
273             final Map.Entry<String, DetailAST> entry = pending.entry();
274             final DetailAST ident = entry.getValue();
275             final String name = entry.getKey();
276             if (!usedFieldIdents.contains(ident) && !globalUsedFields.contains(name)) {
277                 log(ident, MSG_PRIVATE_FIELD, name);
278             }
279         }
280     }
281 
282     /**
283      * Resolves a recorded usage to the exact field declaration it refers to,
284      * following the same shadowing rules Java itself applies.
285      *
286      * @param usage the recorded usage to resolve.
287      * @return the DetailAST of the field it resolves to, or null if none.
288      */
289     private static DetailAST resolveUsage(FieldUsage usage) {
290         DetailAST result = null;
291         if (usage.qualifierTypeName() != null) {
292             final int index = usage.ancestorTypeNames().indexOf(usage.qualifierTypeName());
293             if (index != -1) {
294                 result = usage.ancestorFieldMaps().get(index).get(usage.name());
295             }
296         }
297         else {
298             for (final Map<String, DetailAST> level : usage.ancestorFieldMaps()) {
299                 result = level.get(usage.name());
300                 if (result != null) {
301                     break;
302                 }
303             }
304         }
305         return result;
306     }
307 
308     /**
309      * Collects private field declarations.
310      *
311      * @param ast for this method.
312      */
313     private void handleVariableDef(DetailAST ast) {
314         final DetailAST parent = ast.getParent();
315 
316         if (parent.getType() == TokenTypes.OBJBLOCK) {
317             final DetailAST modifiers = ast.findFirstToken(TokenTypes.MODIFIERS);
318             final boolean isPrivateField = isPrivate(modifiers);
319             final DetailAST ident = ast.findFirstToken(TokenTypes.IDENT);
320             final boolean isIgnoredName =
321                     ignoredFieldPattern.matcher(ident.getText()).matches();
322             final boolean isIgnored = isIgnoredName || hasIgnoredAnnotation(ast);
323             if (isPrivateField && !isIgnored) {
324                 privateFields.peek().put(ident.getText(), ident);
325             }
326         }
327         else if (!scopeStack.isEmpty()) {
328             final String localName =
329                     ast.findFirstToken(TokenTypes.IDENT).getText();
330             scopeStack.peek().put(localName, resolveDeclaredTypeName(ast));
331         }
332     }
333 
334     /**
335      * Checks whether a field should be ignored because either the field itself or its
336      * enclosing type (class, interface, enum, or record) carries an annotation present
337      * in {@link #ignoreAnnotationCanonicalNames} (matched by canonical or short name).
338      *
339      * @param variableDef the VARIABLE_DEF node.
340      * @return true if the field or its enclosing type has a matching ignore annotation.
341      */
342     private boolean hasIgnoredAnnotation(final DetailAST variableDef) {
343         boolean result = isAnnotatedWithIgnoredAnnotation(variableDef);
344         if (!result) {
345             final DetailAST classDef = variableDef.getParent().getParent();
346             result = isAnnotatedWithIgnoredAnnotation(classDef);
347         }
348         return result;
349     }
350 
351     /**
352      * Checks whether the given AST node (a field or a type definition) carries an
353      * annotation present in {@link #ignoreAnnotationCanonicalNames}, matched either
354      * by canonical name or by short name.
355      *
356      * @param ast the VARIABLE_DEF, CLASS_DEF, INTERFACE_DEF, ENUM_DEF, RECORD_DEF, or
357      *            ANNOTATION_DEF node to inspect.
358      * @return true if a matching ignore annotation is present directly on {@code ast}.
359      */
360     private boolean isAnnotatedWithIgnoredAnnotation(final DetailAST ast) {
361         boolean result = false;
362         final DetailAST holder = AnnotationUtil.getAnnotationHolder(ast);
363         if (holder != null) {
364             DetailAST child = holder.getFirstChild();
365             while (child != null) {
366                 if (child.getType() == TokenTypes.ANNOTATION) {
367                     final String name =
368                             FullIdent.createFullIdent(
369                                     child.getFirstChild().getNextSibling()).getText();
370                     if (ignoreAnnotationCanonicalNames.contains(name)
371                             || ignoreAnnotationShortNames.contains(name)) {
372                         result = true;
373                         break;
374                     }
375                 }
376                 child = child.getNextSibling();
377             }
378         }
379         return result;
380     }
381 
382     /**
383      * Records field usage, respecting local variable and parameter shadowing.
384      * Resolution to a specific field declaration happens later, at
385      * {@link #finishTree}.
386      *
387      * @param ast for handleIdent
388      */
389     private void handleIdent(DetailAST ast) {
390         final DetailAST parent = ast.getParent();
391         if (!isDeclarationParent(parent)) {
392             final String name = ast.getText();
393             final boolean shadowed =
394                     scopeStack.stream().anyMatch(scope -> scope.containsKey(name));
395             if (parent.getType() == TokenTypes.DOT) {
396                 handleDotAccess(parent, name);
397             }
398             else if (!shadowed) {
399                 recordUsage(name, null, false);
400             }
401         }
402     }
403 
404     /**
405      * Classifies a dot-qualified reference: {@code this.field} and
406      * {@code ClassName.this.field} are resolved precisely; any other qualifier
407      * (an arbitrary object or type reference) cannot be resolved without type
408      * information, so it falls back to name-only matching via
409      * {@link #globalUsedFields}.
410      *
411      * @param dot  the DOT node whose last child is the field IDENT.
412      * @param name the field name being accessed.
413      */
414     private void handleDotAccess(DetailAST dot, String name) {
415         final DetailAST qualifier = dot.getFirstChild();
416         if (qualifier.getType() == TokenTypes.LITERAL_THIS) {
417             recordUsage(name, null, true);
418         }
419         else if (qualifier.getType() == TokenTypes.DOT
420                 && qualifier.getLastChild().getType() == TokenTypes.LITERAL_THIS) {
421             final String qualifiedName =
422                     FullIdent.createFullIdent(qualifier.getFirstChild()).getText();
423             final String simpleName =
424                     qualifiedName.substring(qualifiedName.lastIndexOf('.') + 1);
425             recordUsage(name, simpleName, false);
426         }
427         else if (findDeclaredType(qualifier.getText()) != null) {
428             typedUsages.add(new TypedUsage(findDeclaredType(qualifier.getText()), name));
429         }
430         else {
431             globalUsedFields.add(name);
432         }
433     }
434 
435     /**
436      * Looks up the declared simple type name of a local variable or parameter
437      * currently in scope.
438      *
439      * @param name the variable or parameter name.
440      * @return its declared simple type name, or null if the name is not a
441      *         tracked local/parameter, or its type could not be determined.
442      */
443     private String findDeclaredType(String name) {
444         String result = null;
445         for (final Map<String, String> scope : scopeStack) {
446             final String type = scope.get(name);
447             if (type != null) {
448                 result = type;
449                 break;
450             }
451         }
452         return result;
453     }
454 
455     /**
456      * Extracts the declared simple type name of a VARIABLE_DEF or
457      * PARAMETER_DEF, if determinable.
458      *
459      * @param varOrParamDef the VARIABLE_DEF or PARAMETER_DEF node.
460      * @return the simple type name, or null if it could not be determined
461      *         (e.g. primitive types, inferred/generic-only forms).
462      */
463     private static String resolveDeclaredTypeName(DetailAST varOrParamDef) {
464         final DetailAST typeAst = varOrParamDef.findFirstToken(TokenTypes.TYPE);
465         String result = null;
466         final DetailAST identChild = typeAst.findFirstToken(TokenTypes.IDENT);
467         if (identChild != null) {
468             result = identChild.getText();
469         }
470         return result;
471     }
472 
473     /**
474      * Snapshots the currently open class chain (field maps and type names) so the
475      * usage can be resolved later, once every class in the file is fully populated.
476      *
477      * @param name              the field name referenced.
478      * @param qualifierTypeName the enclosing type name named in a
479      *                          {@code ClassName.this.field} reference, or null.
480      * @param bareThisQualified true for a {@code this.field} reference.
481      */
482     private void recordUsage(String name, String qualifierTypeName, boolean bareThisQualified) {
483         fieldUsages.add(new FieldUsage(name,
484                 new ArrayList<>(privateFields),
485                 new ArrayList<>(enclosingTypeNames),
486                 qualifierTypeName,
487                 bareThisQualified));
488     }
489 
490     /**
491      * Checks whether the given parent node is a declaration site whose IDENT child
492      * names the declared element itself (a variable, method, constructor, or type),
493      * rather than referencing some other field.
494      *
495      * @param parent the parent of the IDENT being inspected.
496      * @return true if the IDENT is a declaration name, not a usage.
497      */
498     private static boolean isDeclarationParent(DetailAST parent) {
499         final int type = parent.getType();
500         return type == TokenTypes.VARIABLE_DEF
501                 || type == TokenTypes.METHOD_DEF
502                 || type == TokenTypes.CLASS_DEF
503                 || type == TokenTypes.INTERFACE_DEF
504                 || type == TokenTypes.ENUM_DEF
505                 || type == TokenTypes.RECORD_DEF
506                 || type == TokenTypes.ANNOTATION_DEF;
507     }
508 
509     /**
510      * Checks whether a field is private.
511      *
512      * @param modifiers for isPrivate method.
513      * @return modifiers of literal_private.
514      */
515     private static boolean isPrivate(final DetailAST modifiers) {
516         return modifiers.findFirstToken(TokenTypes.LITERAL_PRIVATE) != null;
517     }
518 
519     /**
520      * Holds a private field entry, reported if never resolved to by any usage.
521      *
522      * @param entry The field name and its AST node.
523      */
524     private record PendingField(Map.Entry<String, DetailAST> entry) {
525     }
526 
527     /**
528      * A recorded field-name access on a qualifier whose declared type is
529      * known precisely.
530      *
531      * @param typeName  the qualifier's declared simple type name.
532      * @param fieldName the field name accessed on it.
533      */
534     private record TypedUsage(String typeName, String fieldName) {
535     }
536 
537     /**
538      * A recorded, not-yet-resolved reference to a field name, with enough context
539      * to resolve it precisely once the whole file has been visited.
540      *
541      * @param name                the field name referenced.
542      * @param ancestorFieldMaps   the currently open field maps at the time of the
543      *                            reference, innermost first (index 0).
544      * @param ancestorTypeNames   the currently open type names, parallel to
545      *                            {@code ancestorFieldMaps}.
546      * @param qualifierTypeName   the type name in a {@code ClassName.this.field}
547      *                            reference, or null if not that form.
548      * @param bareThisQualified   true for a {@code this.field} reference.
549      */
550     private record FieldUsage(String name, List<Map<String, DetailAST>> ancestorFieldMaps,
551                               List<String> ancestorTypeNames, String qualifierTypeName,
552                               boolean bareThisQualified) {
553     }
554 
555 }