ForbidCertainMethodCheck.java
////////////////////////////////////////////////////////////////////////////////
// checkstyle: Checks Java source code for adherence to a set of rules.
// Copyright (C) 2001-2019 the original author or authors.
//
// This library is free software; you can redistribute it and/or
// modify it under the terms of the GNU Lesser General Public
// License as published by the Free Software Foundation; either
// version 2.1 of the License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
////////////////////////////////////////////////////////////////////////////////
package com.github.sevntu.checkstyle.checks.coding;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.github.sevntu.checkstyle.SevntuUtil;
import com.puppycrawl.tools.checkstyle.api.AbstractCheck;
import com.puppycrawl.tools.checkstyle.api.CheckstyleException;
import com.puppycrawl.tools.checkstyle.api.DetailAST;
import com.puppycrawl.tools.checkstyle.api.TokenTypes;
import com.puppycrawl.tools.checkstyle.utils.CommonUtil;
/**
* Check that forbidden method is not used. We can forbid a method by method name and number of
* arguments.
* This can be used to enforce things like:
* <ul>
* <li> exit() method of System class should not be called.</li>
* <li> assertTrue() and assertFalse() methods of Assert class have a 1 arg variant that does not
* provide a helpful message on failure. These methods should not be used.
* </ul>
* Parameters are:
* <ul>
* <li><b>methodName</b> - Regex to match name of the method to be forbidden.
* When blank or unspecified, all the methods are allowed.</li>
* <li><b>argumentCount</b> - Number or range to match number of arguments the method takes.
* Multiple numbers/ranges must be comma separated. When unspecified, defaults to "0-".
* </ul>
*
* <p>An example configuration:
* <pre>
* <module name="ForbidCertainMethodCheck">
* <property name="methodName" value="exit"/>
* </module>
* <module name="ForbidCertainMethodCheck">
* <property name="methodName" value="assert(True|False)"/>
* <property name="argumentCount" value="1"/>
* </module>
* <module name="ForbidCertainMethodCheck">
* <property name="methodName" value="assertEquals"/>
* <property name="argumentCount" value="2"/>
* </module>
* </pre>
* Argument count can be bounded range (e.g.: {@code 2-4}) or unbounded range
* (e.g.: {@code -5, 6-}). Unbounded range can be unbounded only on one side.
* Multiple ranges must be comma separated.
* For example, the following will allow only 4 and 8 arguments.
*
* <pre>
* <module name="ForbidCertainMethodCheck">
* <property name="methodName" value="asList"/>
* <property name="argumentCount" value="-3, 5-7, 9-"/>
* </module>
* </pre>
*
* <p>
* Note: The check only matches method name. Matching on class/object of the
* method is not done. For e.g. there is no way to forbid only "System.exit()". You can match
* by methodName="exit", but beware that it will violate "System.exit()" and "MySystem.exit()",
* so use it with caution.
* </p>
*
* @author <a href="mailto:raghavgautam@gmail.com">Raghav Kumar Gautam</a>
* @since 1.28.0
*/
public class ForbidCertainMethodCheck extends AbstractCheck {
/** Key is pointing to the warning message text in "messages.properties" file. */
public static final String MSG_KEY = "forbid.certain.method";
/** Regex for splitting string on comma. */
private static final Pattern COMMA_REGEX = Pattern.compile(",");
/** Name of the method. */
private Pattern methodName = CommonUtil.createPattern("^$");
/** Range for number of arguments. */
private String argumentCount = "0-";
/** Range objects for matching number of arguments. */
private final List<IntRange> argumentCountRanges = new ArrayList<>(
Collections.singletonList(new IntRange(0, Integer.MAX_VALUE)));
/**
* Set method name regex for the forbidden method.
* @param methodName regex for the method name
*/
public void setMethodName(String methodName) {
this.methodName = CommonUtil.createPattern(methodName);
}
/**
* Set number or range to match number of arguments of the forbidden method.
* Multiple values must be comma separated.
* @param argumentCount range for matching number of arguments
* @throws CheckstyleException when argumentCount is not a valid range
*/
public void setArgumentCount(String argumentCount) throws CheckstyleException {
this.argumentCount = argumentCount;
if (CommonUtil.isBlank(argumentCount)) {
throw new CheckstyleException(
"argumentCount must be non-empty, found: " + argumentCount);
}
final String[] rangeTokens = COMMA_REGEX.split(argumentCount);
argumentCountRanges.clear();
for (String oneToken : rangeTokens) {
argumentCountRanges.add(IntRange.from(oneToken));
}
}
@Override
public int[] getDefaultTokens() {
return getRequiredTokens();
}
@Override
public int[] getAcceptableTokens() {
return getRequiredTokens();
}
@Override
public int[] getRequiredTokens() {
return new int[] {
TokenTypes.METHOD_CALL,
};
}
@Override
public void visitToken(DetailAST ast) {
if (ast.getType() == TokenTypes.METHOD_CALL) {
final DetailAST dot = ast.getFirstChild();
// method that looks like: method()
final String methodNameInCode;
if (dot.getType() == TokenTypes.IDENT) {
methodNameInCode = dot.getText();
}
// method that looks like: obj.method()
else {
methodNameInCode = dot.getLastChild().getText();
}
final int numArgsInCode = getMethodCallParameterCount(ast);
if (isForbiddenMethod(methodNameInCode, numArgsInCode)) {
log(ast, MSG_KEY, methodNameInCode, methodName,
numArgsInCode, argumentCount);
}
}
else {
SevntuUtil.reportInvalidToken(ast.getType());
}
}
/**
* Count the parameters given to a method call.
* @param ast The method call AST.
* @return The number of parameters.
*/
private static int getMethodCallParameterCount(DetailAST ast) {
int paramCount = 0;
final DetailAST expressionList = ast.getFirstChild().getNextSibling();
// This works by counting the number of commas separating the
// expressions passed to the method, if any
if (expressionList.getChildCount() > 0) {
// We have at least one parameter, so the total number of
// parameters is the number of commas plus one
paramCount = expressionList.getChildCount(TokenTypes.COMMA) + 1;
}
return paramCount;
}
/**
* Check if the method/constructor call against defined rules.
* @param name ruleName of the the method
* @param argCount number of arguments of the method
* @return true if method name and argument matches, false otherwise.
*/
private boolean isForbiddenMethod(String name, int argCount) {
boolean matched = false;
if (methodName.matcher(name).matches()) {
for (IntRange intRange : argumentCountRanges) {
if (intRange.contains(argCount)) {
matched = true;
break;
}
}
}
return matched;
}
/**
* Represents a range of non-negative integers.
* Range must be bounded on one side or both sides.
* It can't be unbounded on both side.
* <br>
* Some examples of valid ranges:
* <ul>
* <li>1-10: 1 and 10 and all numbers between 1 and 10</li>
* <li>-10: same as 0-10</li>
* <li>5-: same as 5-infinity</li>
* <li>1: same as 1-1</li>
* </ul>
*/
/* package */ static class IntRange {
/** Regex for matching range. */
private static final Pattern RANGE_PATTERN =
Pattern.compile("^\\s*+(\\d*+)\\s*+-\\s*+(\\d*+)\\s*+$");
/** Lower limit of the range. No lower limit when null. */
private final int lowerLimit;
/** Upper limit of the range. No upper limit when null. */
private final int upperLimit;
/**
* Initialize IntRange object with a lower limit and an upper limit.
* @param lowerLimit lower limit of the range, must be >= 0, null is equivalent to 0
* @param upperLimit upper limit of the range, null is equivalent to infinity
*/
/* package */ IntRange(int lowerLimit, int upperLimit) {
this.lowerLimit = lowerLimit;
this.upperLimit = upperLimit;
}
/**
* Create a range object corresponding to it string representation.
* @param range string representation of the range
* @return IntRange object for the string
*
* @throws CheckstyleException if the specified range is not valid
*/
private static IntRange from(String range) throws CheckstyleException {
int lowerLimit = 0;
int upperLimit = Integer.MAX_VALUE;
if (range.contains("-")) {
final Matcher matcher = RANGE_PATTERN.matcher(range);
if (!matcher.find()) {
throw new CheckstyleException("Specified range is not valid: " + range);
}
final String lowerLimitString = matcher.group(1);
final String upperLimitString = matcher.group(2);
if (lowerLimitString.length() == 0 && upperLimitString.length() == 0) {
throw new CheckstyleException("Specified range is unbounded on both side: "
+ range);
}
if (lowerLimitString.length() > 0) {
lowerLimit = Integer.parseInt(lowerLimitString);
}
if (upperLimitString.length() > 0) {
upperLimit = Integer.parseInt(upperLimitString);
}
if (lowerLimit > upperLimit) {
throw new CheckstyleException(
"Lower limit of the range is larger than the upper limit: " + range);
}
}
else {
lowerLimit = Integer.parseInt(range.trim());
upperLimit = lowerLimit;
}
return new IntRange(lowerLimit, upperLimit);
}
/**
* Check if range contain given number. Range is closed.
* If lower/upper bound is absent, it is considered unbounded on lower/upper side.
* @param num the number to be checked
* @return true if number is contained in the range, false otherwise
*/
public boolean contains(int num) {
return num >= lowerLimit && num <= upperLimit;
}
}
}