EitherLogOrThrowCheck.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.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.regex.Pattern;

import com.github.sevntu.checkstyle.SevntuUtil;
import com.puppycrawl.tools.checkstyle.api.AbstractCheck;
import com.puppycrawl.tools.checkstyle.api.DetailAST;
import com.puppycrawl.tools.checkstyle.api.FullIdent;
import com.puppycrawl.tools.checkstyle.api.TokenTypes;

/**
 * <p>
 * Either log the exception, or throw it, but never do both. Logging and
 * throwing results in multiple log messages for a single problem in the code,
 * and makes problems for the support engineer who is trying to dig through the
 * logs. This is one of the most annoying error-handling antipatterns. All of
 * these examples are equally wrong.
 * </p>
 * <p>
 * <b>Examples:</b>
 * </p>
 *
 * <pre>
 * catch (NoSuchMethodException e) {
 *     LOG.error("Message", e);
 *     throw e;
 * }
 * </pre>
 *
 * <b>or</b>
 *
 * <pre>
 * catch (NoSuchMethodException e) {
 *     LOG.error("Message", e);
 *     throw new MyServiceException("AnotherMessage", e);
 * }
 * </pre>
 *
 * <b>or</b>
 *
 * <pre>
 * catch (NoSuchMethodException e) {
 *      e.printStackTrace();
 *      throw new MyServiceException("Message", e);
 * }
 * </pre>
 *
 * <p>
 * <b>What check can detect:</b> <br>
 * <b>Loggers</b>
 * </p>
 * <ul>
 * <li>logger is declared as class field</li>
 * <li>logger is declared as method's local variable</li>
 * <li>logger is declared as local variable in <code>catch</code> block</li>
 * <li>logger is passed through method's parameters</li>
 * </ul>
 * <b>Exceptions</b>
 * <ul>
 * <li>logger logs <code>catch</code> parameter exception or it's message</li>
 * <li>throw <code>catch</code> parameter exception</li>
 * <li>throw another exception which is based on <code>catch</code> parameter
 * exception</li>
 * <li>printStackTrace was called on <code>catch</code> parameter exception</li>
 * </ul>
 * <p>
 * <b>What check can not detect:</b> <br>
 * </p>
 * <ul>
 * <li>loggers that is used like method's return value. Example:
 *
 * <pre>
 * getLogger().error(&quot;message&quot;, e)
 * </pre>
 *
 * </li>
 * <li>loggers that is used like static fields from another classes:
 *
 * <pre>
 * MyAnotherClass.LOGGER.error("message", e);
 * </pre>
 * </li>
 * </ul>
 * <p>
 * Default parameters are:
 * </p>
 * <ul>
 * <li><b>loggerFullyQualifiedClassName</b> - fully qualified class name of
 * logger type. Default value is <i>"org.slf4j.Logger"</i>.</li>
 * <li><b>loggingMethodNames</b> - comma separated names of logging methods.
 * Default value is <i>"error, warn, info, debug"</i>.</li>
 * </ul>
 * <p>
 * Note that check works with only one logger type. If you have multiple
 * different loggers, then create another instance of this check.
 * </p>
 * @author <a href="mailto:barataliba@gmail.com">Baratali Izmailov</a>
 * @since 1.9.0
 */
public class EitherLogOrThrowCheck extends AbstractCheck {

    /**
     * Key for error message.
     */
    public static final String MSG_KEY = "either.log.or.throw";

    /**
     * Regexp of printStackTrace method.
     */
    private static final Pattern PRINT_STACK_TRACE_METHOD_PATTERN = Pattern
            .compile(".+\\.printStackTrace");

    /**
     * Variables names of logger variables.
     */
    private final List<String> loggerFieldNames = new LinkedList<>();

    /**
     * Current local variable names of logger type. It can be method's parameter
     * or method's local variable.
     */
    private final List<String> currentLocalLoggerVariableNames = new ArrayList<>();

    /**
     * Logger fully qualified class name.
     */
    private String loggerFullyQualifiedClassName = "org.slf4j.Logger";

    /**
     * Logger class name.
     */
    private String loggerSimpleClassName = "Logger";

    /**
     * Logger method names.
     */
    private List<String> loggingMethodNames =
            Arrays.asList("error", "warn", "info", "debug");

    /**
     * Logger class is in imports.
     */
    private boolean hasLoggerClassInImports;

    /**
     * Considered class definition.
     */
    private DetailAST currentClassDefAst;

    /**
     * Considered method definition.
     */
    private DetailAST currentMethodDefAst;

    /**
     * Set logger full class name and logger simple class name.
     * @param loggerFullyQualifiedClassName
     *        Logger full class name. Example: org.slf4j.Logger.
     */
    public void setLoggerFullyQualifiedClassName(
            String loggerFullyQualifiedClassName) {
        this.loggerFullyQualifiedClassName = loggerFullyQualifiedClassName;
        loggerSimpleClassName = loggerFullyQualifiedClassName;
        final int lastDotIndex =
                this.loggerFullyQualifiedClassName.lastIndexOf('.');
        if (lastDotIndex != -1) {
            loggerSimpleClassName = this.loggerFullyQualifiedClassName
                    .substring(lastDotIndex + 1);
        }
    }

    /**
     * Set logging method names.
     * @param loggingMethodNames Logger method names.
     */
    public void setLoggingMethodNames(String... loggingMethodNames) {
        this.loggingMethodNames = Arrays.asList(loggingMethodNames);
    }

    @Override
    public int[] getDefaultTokens() {
        return new int[] {
            TokenTypes.IMPORT,
            TokenTypes.CLASS_DEF,
            TokenTypes.LITERAL_CATCH,
            TokenTypes.VARIABLE_DEF,
            TokenTypes.METHOD_DEF, };
    }

    @Override
    public int[] getAcceptableTokens() {
        return getDefaultTokens();
    }

    @Override
    public int[] getRequiredTokens() {
        return getDefaultTokens();
    }

    @Override
    public void visitToken(final DetailAST ast) {
        switch (ast.getType()) {
            case TokenTypes.IMPORT:
                if (!hasLoggerClassInImports
                    && isLoggerImport(ast)) {
                    hasLoggerClassInImports = true;
                }
                break;
            case TokenTypes.CLASS_DEF:
                if (!isInnerClass(ast)) {
                    currentClassDefAst = ast;
                    collectLoggerFieldNames(ast);
                }
                break;
            case TokenTypes.METHOD_DEF:
                if (isMethodOfCurrentClass(ast)) {
                    currentMethodDefAst = ast;
                    currentLocalLoggerVariableNames.clear();
                    final DetailAST parametersAst = currentMethodDefAst
                        .findFirstToken(TokenTypes.PARAMETERS);
                    collectLoggersFromParameters(parametersAst);
                }
                break;
            case TokenTypes.VARIABLE_DEF:
                final DetailAST methodDefAst = ast.getParent().getParent();
                if (methodDefAst == currentMethodDefAst
                    && methodDefAst.getType() == TokenTypes.METHOD_DEF
                    && isLoggerVariableDefinition(ast)) {
                    currentLocalLoggerVariableNames.add(getIdentifier(ast));
                }
                break;
            case TokenTypes.LITERAL_CATCH:
                processCatchNode(ast);
                break;
            default:
                SevntuUtil.reportInvalidToken(ast.getType());
                break;
        }
    }

    /**
     * Checks if AST object is logger import.
     * @param importAst
     *        DetailAST of import statement.
     * @return true if import equals logger full class name.
     */
    private boolean isLoggerImport(final DetailAST importAst) {
        final String importIdentifier =
                FullIdent.createFullIdent(importAst.getFirstChild()).getText();
        return loggerFullyQualifiedClassName.equals(importIdentifier);
    }

    /**
     * Verify that class is inner.
     * @param classDefAst
     *        DetailAST of class definition.
     * @return true if class is inner, false otherwise.
     */
    private boolean isInnerClass(final DetailAST classDefAst) {
        boolean result = false;
        DetailAST parentAst = classDefAst.getParent();
        while (parentAst != null) {
            if (parentAst == currentClassDefAst) {
                result = true;
                break;
            }
            parentAst = parentAst.getParent();
        }
        return result;
    }

    /**
     * Save names of parameters which have logger type.
     * @param parametersAst
     *        DetailAST of parameters.
     */
    private void collectLoggersFromParameters(final DetailAST parametersAst) {
        DetailAST currentParameterAst = parametersAst
                .findFirstToken(TokenTypes.PARAMETER_DEF);
        while (currentParameterAst != null) {
            final DetailAST parameterTypeAst = currentParameterAst
                    .findFirstToken(TokenTypes.TYPE);
            final String className = getIdentifier(parameterTypeAst);
            if (className != null && isLoggerClassName(className)) {
                currentLocalLoggerVariableNames
                        .add(getIdentifier(currentParameterAst));
            }
            currentParameterAst = currentParameterAst.getNextSibling();
        }
    }

    /**
     * Verify that method's parent is class, stored in mCurrentClassDefAst.
     * @param methodDefAst DetailAST of METHOD_DEF.
     * @return true if method's parent is class, stored in mCurrentClassDefAst.
     */
    private boolean isMethodOfCurrentClass(final DetailAST methodDefAst) {
        final DetailAST classDefAst = methodDefAst.getParent().getParent();
        return classDefAst == currentClassDefAst;
    }

    /**
     * Find all logger fields in aClassDefAst and save them.
     * @param classDefAst
     *        DetailAST of class definition.
     */
    private void collectLoggerFieldNames(final DetailAST classDefAst) {
        final DetailAST objBlockAst =
                classDefAst.findFirstToken(TokenTypes.OBJBLOCK);
        DetailAST variableDefAst =
                objBlockAst.findFirstToken(TokenTypes.VARIABLE_DEF);
        while (variableDefAst != null) {
            if (variableDefAst.getType() == TokenTypes.VARIABLE_DEF
                    && isLoggerVariableDefinition(variableDefAst)) {
                loggerFieldNames.add(getIdentifier(variableDefAst));
            }
            variableDefAst = variableDefAst.getNextSibling();
        }
    }

    /**
     * Look at the each statement of catch block to find logging and throwing.
     * If same exception is being logged and throwed, then prints warning
     * message.
     * @param catchAst
     *        DetailAST of catch block.
     */
    private void processCatchNode(final DetailAST catchAst) {
        boolean isLoggingExceptionFound = false;
        DetailAST loggingExceptionAst = null;
        final List<String> exceptionVariableNames = new LinkedList<>();
        final String catchParameterName = getCatchParameterName(catchAst);
        final DetailAST statementsAst =
                catchAst.findFirstToken(TokenTypes.SLIST);
        DetailAST currentStatementAst = statementsAst.getFirstChild();
        while (currentStatementAst != null) {
            switch (currentStatementAst.getType()) {
                // local logger or exception variable definition
                case TokenTypes.VARIABLE_DEF:
                    if (isLoggerVariableDefinition(currentStatementAst)) {
                        currentLocalLoggerVariableNames
                            .add(getIdentifier(currentStatementAst));
                    }
                    else {
                        final DetailAST assignAst = currentStatementAst
                            .findFirstToken(TokenTypes.ASSIGN);
                        if (assignAst != null
                            && isInstanceCreationBasedOnException(
                                assignAst.getFirstChild(),
                                 catchParameterName)) {
                            exceptionVariableNames
                                .add(getIdentifier(currentStatementAst));
                        }
                    }
                    break;
                    // logging exception or printStackTrace
                case TokenTypes.EXPR:
                    if (!isLoggingExceptionFound
                        && (isLoggingExceptionArgument(currentStatementAst, catchParameterName)
                        || isPrintStackTrace(currentStatementAst, catchParameterName))) {
                        isLoggingExceptionFound = true;
                        loggingExceptionAst = currentStatementAst;
                    }
                    break;
                    // throw exception
                case TokenTypes.LITERAL_THROW:
                    if (isLoggingExceptionFound) {
                        exceptionVariableNames.add(catchParameterName);
                        final DetailAST thrownExceptionAst = currentStatementAst
                            .getFirstChild();
                        if (exceptionVariableNames.contains(getIdentifier(thrownExceptionAst))
                            || isInstanceCreationBasedOnException(
                                thrownExceptionAst, catchParameterName)) {
                            log(loggingExceptionAst, MSG_KEY);
                            break;
                        }
                    }
                    break;
                default:
                    // rest tokens shall be skipped
                    break;
            }
            currentStatementAst = currentStatementAst.getNextSibling();
        }
    }

    /**
     * Verify that aVariableDefAst is variable of logger type.
     * @param variableDefAst
     *        DetailAST of variable definition.
     * @return true if variable is of logger type.
     */
    private boolean isLoggerVariableDefinition(final DetailAST variableDefAst) {
        final DetailAST variableTypeAst =
                variableDefAst.findFirstToken(TokenTypes.TYPE).getFirstChild();
        final String variableTypeName =
                FullIdent.createFullIdent(variableTypeAst).getText();
        return isLoggerClassName(variableTypeName);
    }

    /**
     * Verify that aClassName is class name of logger type.
     * @param className name of checked class.
     * @return true aClassName is class name of logger type.
     */
    private boolean isLoggerClassName(String className) {
        return hasLoggerClassInImports
                && className.equals(loggerSimpleClassName)
                || className.equals(loggerFullyQualifiedClassName);
    }

    /**
     * Get parameter name of catch block.
     * @param catchAst
     *        DetailAST of catch block.
     * @return name of parameter.
     */
    private static String getCatchParameterName(final DetailAST catchAst) {
        final DetailAST parameterDefAst =
                catchAst.findFirstToken(TokenTypes.PARAMETER_DEF);
        return getIdentifier(parameterDefAst);
    }

    /**
     * Get identifier of AST. These can be names of types, subpackages, fields,
     * methods, parameters, and local variables.
     * @param ast
     *        DetailAST instance
     * @return identifier of AST, null if AST does not have name.
     */
    private static String getIdentifier(final DetailAST ast) {
        String result = null;
        if (ast != null) {
            final DetailAST identAst = ast.findFirstToken(TokenTypes.IDENT);
            if (identAst != null) {
                result = identAst.getText();
            }
        }
        return result;
    }

    /**
     * Verify that expression is creating instance. And this instance is created
     * with exception argument. Example: new MyException("message", exception).
     * @param expressionAst
     *        DetailAST of expression.
     * @param exceptionArgumentName
     *        Exception argument name.
     * @return true if given expression is creating new exception based on
     *         another exception object named aExeceptionParameterName.
     */
    private static boolean isInstanceCreationBasedOnException(
            final DetailAST expressionAst, final String exceptionArgumentName) {
        boolean result = false;
        final DetailAST literalNewAst =
                expressionAst.findFirstToken(TokenTypes.LITERAL_NEW);
        if (literalNewAst != null) {
            final DetailAST parametersAst = literalNewAst
                    .findFirstToken(TokenTypes.ELIST);
            if (parametersAst != null) {
                result = containsExceptionParameter(parametersAst,
                        exceptionArgumentName);
            }
        }
        return result;
    }

    /**
     * Verify that expression is logging exception.
     * @param expressionAst DetailAST of expression(EXPR).
     * @param exceptionVariableName name of exception variable.
     * @return true if expression is logging exception.
     */
    private boolean isLoggingExceptionArgument(
            final DetailAST expressionAst, final String exceptionVariableName) {
        boolean result = false;
        if (isLoggingExpression(expressionAst)) {
            final DetailAST loggingMethodCallAst =
                    expressionAst.getFirstChild();
            final DetailAST loggerParametersAst =
                    loggingMethodCallAst.findFirstToken(TokenTypes.ELIST);
            result = containsExceptionParameter(
                    loggerParametersAst, exceptionVariableName);
        }
        return result;
    }

    /**
     * Verify that aExpressionAst is a logging expression.
     * @param expressionAst
     *        DetailAST of expression.
     * @return true if aExpressionAst is a logging expression.
     */
    private boolean isLoggingExpression(final DetailAST expressionAst) {
        boolean result = false;
        final DetailAST methodCallAst = expressionAst.getFirstChild();
        if (methodCallAst.getType() == TokenTypes.METHOD_CALL
                && hasChildToken(methodCallAst, TokenTypes.DOT)) {
            final DetailAST dotAst = methodCallAst.getFirstChild();
            final DetailAST loggerObjectAst = dotAst.getFirstChild();
            final DetailAST invokedMethodAst = loggerObjectAst.getNextSibling();
            final String loggerObjectIdentifier =
                    FullIdent.createFullIdent(loggerObjectAst).getText();
            final String invokedMethodIdentifier = invokedMethodAst.getText();
            result = (currentLocalLoggerVariableNames
                    .contains(loggerObjectIdentifier)
                    || loggerFieldNames.contains(loggerObjectIdentifier))
                    && loggingMethodNames.contains(invokedMethodIdentifier);
        }
        return result;
    }

    /**
     * Verify that aExceptionVariableName is in aParametersAst.
     * @param parametersAst
     *            DetailAST of expression list(ELIST).
     * @param exceptionVariableName
     *            name of exception.
     * @return true if aExceptionVariableName is in aParametersAst.
     */
    private static boolean containsExceptionParameter(
            final DetailAST parametersAst, final String exceptionVariableName) {
        boolean result = false;
        DetailAST parameterAst = parametersAst.getFirstChild();

        while (parameterAst != null) {
            if (exceptionVariableName.equals(getIdentifier(parameterAst))
                    || isInstanceMethodCall(exceptionVariableName,
                            parameterAst.getFirstChild())) {
                result = true;
                parameterAst = null;
            }
            else {
                parameterAst = parameterAst.getNextSibling();
            }
        }
        return result;
    }

    /**
     * Verify that expression is call of exception's printStackTrace method.
     * @param expressionAst
     *        DetailAST of expression.
     * @param exceptionVariableName
     *        name of exception variable.
     * @return true if expression is call of exception's printStackTrace method.
     */
    private static boolean isPrintStackTrace(final DetailAST expressionAst,
            final String exceptionVariableName) {
        boolean result = false;
        final DetailAST methodCallAst = expressionAst.getFirstChild();
        if (isInstanceMethodCall(exceptionVariableName, methodCallAst)) {
            final String methodCallStr =
                    FullIdent.createFullIdentBelow(methodCallAst).getText();
            if (PRINT_STACK_TRACE_METHOD_PATTERN.matcher(methodCallStr).matches()) {
                result = true;
            }
        }
        return result;
    }

    /**
     * Verify that method is invoked on aUsedInstanceName.
     * @param usedInstanceName name of instance.
     * @param methodCallAst DetailAST of METHOD_CALL.
     * @return true if method is invoked on aUsedInstanceName.
     */
    private static boolean isInstanceMethodCall(final String usedInstanceName,
            final DetailAST methodCallAst) {
        boolean result = false;
        if (methodCallAst != null
                && methodCallAst.getType() == TokenTypes.METHOD_CALL) {
            final String methodCallIdent =
                    FullIdent.createFullIdentBelow(methodCallAst).getText();
            final int firstDotIndex = methodCallIdent.indexOf('.');
            if (firstDotIndex != -1) {
                final String usedObjectName =
                        methodCallIdent.substring(0, firstDotIndex);
                if (usedObjectName.equals(usedInstanceName)) {
                    result = true;
                }
            }
        }
        return result;
    }

    /**
     * Return true if aAST has token of aTokenType type.
     * @param ast
     *        DetailAST instance.
     * @param tokenType
     *        one of TokenTypes
     * @return true if aAST has token of given type, or false otherwise.
     */
    private static boolean hasChildToken(final DetailAST ast, int tokenType) {
        return ast.findFirstToken(tokenType) != null;
    }

}