/* * SonarQube Java * Copyright (C) 2012-2016 SonarSource SA * mailto:contact AT sonarsource DOT com * * This program 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 3 of the License, or (at your option) any later version. * * This program 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 program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ package org.sonar.java.checks; import com.google.common.collect.ImmutableList; import com.google.common.collect.Sets; import org.sonar.check.Rule; import org.sonar.java.checks.methods.AbstractMethodDetection; import org.sonar.java.matcher.MethodMatcher; import org.sonar.java.matcher.TypeCriteria; import org.sonar.java.model.LiteralUtils; import org.sonar.plugins.java.api.semantic.Type; import org.sonar.plugins.java.api.tree.BinaryExpressionTree; import org.sonar.plugins.java.api.tree.ExpressionTree; import org.sonar.plugins.java.api.tree.LiteralTree; import org.sonar.plugins.java.api.tree.MethodInvocationTree; import org.sonar.plugins.java.api.tree.NewArrayTree; import org.sonar.plugins.java.api.tree.Tree; import javax.annotation.Nullable; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @Rule(key = "S2275") public class PrintfCheck extends AbstractMethodDetection { private static final Pattern PRINTF_PARAM_PATTERN = Pattern.compile("%(\\d+\\$)?([-#+ 0,(\\<]*)?(\\d+)?(\\.\\d+)?([tT])?([a-zA-Z%])"); private static final Set<String> TIME_CONVERSIONS = Sets.newHashSet( "H", "I", "k", "l", "M", "S", "L", "N", "p", "z", "Z", "s", "Q", "B", "b", "h", "A", "a", "C", "Y", "y", "j", "m", "d", "e", "R", "T", "r", "D", "F", "c" ); private static final String FORMAT_METHOD_NAME = "format"; private static final MethodMatcher MESSAGE_FORMAT = MethodMatcher.create().typeDefinition("java.text.MessageFormat").name(FORMAT_METHOD_NAME).withAnyParameters(); private static final MethodMatcher TO_STRING = MethodMatcher.create().typeDefinition(TypeCriteria.anyType()).name("toString").withoutParameter(); private static final Pattern MESSAGE_FORMAT_PATTERN = Pattern.compile("\\{(?<index>\\d+)(?<type>,\\w+)?(?<style>,[^}]*)?\\}"); @Override protected List<MethodMatcher> getMethodInvocationMatchers() { return ImmutableList.of( MethodMatcher.create().typeDefinition("java.lang.String").name(FORMAT_METHOD_NAME).withAnyParameters(), MethodMatcher.create().typeDefinition("java.util.Formatter").name(FORMAT_METHOD_NAME).withAnyParameters(), MethodMatcher.create().typeDefinition("java.io.PrintStream").name(FORMAT_METHOD_NAME).withAnyParameters(), MethodMatcher.create().typeDefinition("java.io.PrintStream").name("printf").withAnyParameters(), MethodMatcher.create().typeDefinition("java.io.PrintWriter").name(FORMAT_METHOD_NAME).withAnyParameters(), MethodMatcher.create().typeDefinition("java.io.PrintWriter").name("printf").withAnyParameters(), MESSAGE_FORMAT ); } @Override protected void onMethodInvocationFound(MethodInvocationTree mit) { ExpressionTree formatStringTree; List<ExpressionTree> args; boolean isMessageFormat = MESSAGE_FORMAT.matches(mit); if (isMessageFormat && !mit.symbol().isStatic()) { // only consider the static method return; } // Check type of first argument: if (mit.arguments().get(0).symbolType().is("java.lang.String")) { formatStringTree = mit.arguments().get(0); args = mit.arguments().subList(1, mit.arguments().size()); } else { // format method with "Locale" first argument, skip that one. formatStringTree = mit.arguments().get(1); args = mit.arguments().subList(2, mit.arguments().size()); } if (formatStringTree.is(Tree.Kind.STRING_LITERAL)) { String formatString = LiteralUtils.trimQuotes(((LiteralTree) formatStringTree).value()); if (isMessageFormat) { handleMessageFormat(mit, formatString, args); } else { handlePrintfFormat(mit, formatString, args); } } else if (isConcatenationOnSameLine(formatStringTree)) { reportIssue(mit, "Format specifiers should be used instead of string concatenation."); } } private void handlePrintfFormat(MethodInvocationTree mit, String formatString, List<ExpressionTree> args) { List<String> params = getParameters(formatString, mit); if (usesMessageFormat(formatString, params)) { reportIssue(mit, "Looks like there is a confusion with the use of java.text.MessageFormat, parameters will be simply ignored here"); return; } checkLineFeed(formatString, mit); if (checkEmptyParams(mit, params)) { return; } cleanupLineSeparator(params); if (!params.isEmpty()) { if (checkArgumentNumber(mit, argIndexes(params).size(), args.size())) { return; } verifyParameters(mit, args, params); } } private void handleMessageFormat(MethodInvocationTree mit, String formatString, List<ExpressionTree> args) { String newFormatString = cleanupDoubleQuote(formatString); Set<Integer> indexes = getMessageFormatIndexes(newFormatString); List<ExpressionTree> newArgs = args; if (newArgs.size() == 1) { ExpressionTree firstArg = newArgs.get(0); if (firstArg.symbolType().isArray()) { if (isNewArrayWithInitializers(firstArg)) { newArgs = ((NewArrayTree) firstArg).initializers(); } else { // size is unknown return; } } } if (checkEmptyParams(mit, indexes) || checkArgumentNumber(mit, indexes.size(), newArgs.size()) || checkUnbalancedQuotes(mit, newFormatString) || checkUnbalancedBraces(mit, newFormatString)) { return; } checkToStringInvocation(newArgs); verifyParameters(mit, newArgs, indexes); } private static boolean isNewArrayWithInitializers(ExpressionTree expression) { return expression.is(Tree.Kind.NEW_ARRAY) && ((NewArrayTree) expression).openBraceToken() != null; } private static String cleanupDoubleQuote(String formatString) { return formatString.replaceAll("\'\'", ""); } private boolean checkEmptyParams(MethodInvocationTree mit, Collection<?> params) { return checkAndReport(mit, params.isEmpty(), "String contains no format specifiers."); } private boolean checkArgumentNumber(MethodInvocationTree mit, int nbReadParams, int nbArgs) { return checkAndReport(mit, nbReadParams > nbArgs, "Not enough arguments."); } private boolean checkAndReport(MethodInvocationTree mit, boolean shouldReport, String message) { if (shouldReport) { reportIssue(mit, message); } return shouldReport; } private boolean checkUnbalancedQuotes(MethodInvocationTree mit, String formatString) { String withoutParam = MESSAGE_FORMAT_PATTERN.matcher(formatString).replaceAll(""); int numberQuote = 0; for (int i = 0; i < withoutParam.length(); ++i) { if (withoutParam.charAt(i) == '\'') { numberQuote++; } } boolean unbalancedQuotes = (numberQuote % 2) != 0; if (unbalancedQuotes) { reportIssue(mit.arguments().get(0), "Single quote \"'\" must be escaped."); } return unbalancedQuotes; } private boolean checkUnbalancedBraces(MethodInvocationTree mit, String formatString) { String withoutParam = MESSAGE_FORMAT_PATTERN.matcher(formatString).replaceAll(""); int numberOpenBrace = 0; for (int i = 0; i < withoutParam.length(); ++i) { char ch = withoutParam.charAt(i); switch (ch) { case '{': numberOpenBrace++; break; case '}': numberOpenBrace--; break; default: break; } } boolean unbalancedBraces = numberOpenBrace > 0; if (unbalancedBraces) { reportIssue(mit.arguments().get(0), "Single left curly braces \"{\" must be escaped."); } return unbalancedBraces; } private void checkToStringInvocation(List<ExpressionTree> args) { args.stream() .filter(arg -> arg.is(Tree.Kind.METHOD_INVOCATION) && TO_STRING.matches((MethodInvocationTree) arg)) .forEach(arg -> reportIssue(arg, "No need to call toString \"method()\" as formatting and string conversion is done by the Formatter.")); } private void verifyParameters(MethodInvocationTree mit, List<ExpressionTree> args, Set<Integer> indexes) { List<ExpressionTree> unusedArgs = new ArrayList<>(args); for (int index : indexes) { if (index >= args.size()) { reportIssue(mit, "Not enough arguments."); return; } unusedArgs.remove(args.get(index)); } reportUnusedArgs(mit, args, unusedArgs); } private static Set<Integer> getMessageFormatIndexes(String formatString) { Matcher matcher = MESSAGE_FORMAT_PATTERN.matcher(formatString); Set<Integer> result = new HashSet<>(); while (matcher.find()) { if (isMessageFormatPattern(formatString, matcher.start())) { result.add(Integer.parseInt(matcher.group("index"))); } } return result; } private static boolean isMessageFormatPattern(String formatString, int start) { return start == 0 || formatString.charAt(start - 1) != '\''; } private static Set<Integer> argIndexes(List<String> params) { int index = 0; Set<Integer> result = new HashSet<>(); for (String rawParam : params) { if (rawParam.contains("$")) { result.add(getIndex(rawParam)); } else if (rawParam.charAt(0) != '<') { index++; result.add(index); } } return result; } private static boolean isConcatenationOnSameLine(ExpressionTree formatStringTree) { return formatStringTree.is(Tree.Kind.PLUS) && operandsAreOnSameLine((BinaryExpressionTree) formatStringTree); } private static boolean operandsAreOnSameLine(BinaryExpressionTree formatStringTree) { return formatStringTree.leftOperand().firstToken().line() == formatStringTree.rightOperand().firstToken().line(); } private static void cleanupLineSeparator(List<String> params) { // Cleanup %n values Iterator<String> iter = params.iterator(); while (iter.hasNext()) { String param = iter.next(); if ("n".equals(param)) { iter.remove(); } } } private void checkLineFeed(String formatString, MethodInvocationTree mit) { if (formatString.contains("\\n")) { reportIssue(mit, "%n should be used in place of \\n to produce the platform-specific line separator."); } } private void verifyParameters(MethodInvocationTree mit, List<ExpressionTree> args, List<String> params) { int index = 0; List<ExpressionTree> unusedArgs = new ArrayList<>(args); for (String rawParam : params) { String param = rawParam; int argIndex = index; if (param.contains("$")) { argIndex = getIndex(param) - 1; if (argIndex == -1) { reportIssue(mit, "Arguments are numbered starting from 1."); return; } param = param.substring(param.indexOf('$') + 1); } else if (param.charAt(0) == '<') { //refers to previous argument argIndex = Math.max(0, argIndex - 1); }else { index++; } ExpressionTree argExpressionTree = args.get(argIndex); unusedArgs.remove(argExpressionTree); Type argType = argExpressionTree.symbolType(); checkNumerical(mit, param, argType); checkBoolean(mit, param, argType); checkTimeConversion(mit, param, argType); } reportUnusedArgs(mit, args, unusedArgs); } private static Integer getIndex(String param) { return Integer.valueOf(param.substring(0, param.indexOf('$'))); } private void checkBoolean(MethodInvocationTree mit, String param, Type argType) { if (param.charAt(0) == 'b' && !(argType.is("boolean") || argType.is("java.lang.Boolean"))) { reportIssue(mit, "Directly inject the boolean value."); } } private void checkNumerical(MethodInvocationTree mit, String param, Type argType) { if (param.charAt(0) == 'd' && !isNumerical(argType)) { reportIssue(mit, "An 'int' is expected rather than a " + argType + "."); } } private void checkTimeConversion(MethodInvocationTree mit, String param, Type argType) { if (param.charAt(0) == 't' || param.charAt(0) == 'T') { String timeConversion = param.substring(1); if (timeConversion.isEmpty()) { reportIssue(mit, "Time conversion requires a second character."); checkTimeTypeArgument(mit, argType); return; } if (!TIME_CONVERSIONS.contains(timeConversion)) { reportIssue(mit, timeConversion + " is not a supported time conversion character"); } checkTimeTypeArgument(mit, argType); } } private void checkTimeTypeArgument(MethodInvocationTree mit, Type argType) { if (!(argType.isNumerical() || isTypeOfAny(argType, "java.lang.Long", "java.time.LocalDate") || isSubtypeOfAny(argType, "java.util.Date", "java.util.Calendar"))) { reportIssue(mit, "Time argument is expected (long, Long, LocalDate, Date or Calendar)."); } } private static boolean isNumerical(Type argType) { return argType.isNumerical() || isTypeOfAny(argType, "java.math.BigInteger", "java.math.BigDecimal", "java.lang.Byte", "java.lang.Short", "java.lang.Integer", "java.lang.Long", "java.lang.Float", "java.lang.Double"); } private static boolean isTypeOfAny(Type argType, String... fullyQualifiedNames) { return Arrays.stream(fullyQualifiedNames).anyMatch(argType::is); } private static boolean isSubtypeOfAny(Type argType, String... fullyQualifiedNames) { return Arrays.stream(fullyQualifiedNames).anyMatch(argType::isSubtypeOf); } private static boolean usesMessageFormat(String formatString, List<String> params) { return params.isEmpty() && (formatString.contains("{0") || formatString.contains("{1")); } private void reportUnusedArgs(MethodInvocationTree mit, List<ExpressionTree> args, List<ExpressionTree> unusedArgs) { for (ExpressionTree unusedArg : unusedArgs) { int i = args.indexOf(unusedArg); String stringArgIndex = "first"; if (i == 1) { stringArgIndex = "2nd"; } else if (i == 2) { stringArgIndex = "3rd"; } else if (i >= 3) { stringArgIndex = (i + 1) + "th"; } reportIssue(mit, stringArgIndex + " argument is not used."); } } private List<String> getParameters(String formatString, MethodInvocationTree mit) { List<String> params = new ArrayList<>(); Matcher matcher = PRINTF_PARAM_PATTERN.matcher(formatString); while (matcher.find()) { if (firstArgumentIsLT(params, matcher.group(2))) { reportIssue(mit, "The argument index '<' refers to the previous format specifier but there isn't one."); continue; } StringBuilder param = new StringBuilder(); for (int groupIndex : new int[] {1, 2, 5, 6}) { if (matcher.group(groupIndex) != null) { param.append(matcher.group(groupIndex)); } } String specifier = param.toString(); if(!"%".equals(specifier)) { params.add(specifier); } } return params; } private static boolean firstArgumentIsLT(List<String> params, @Nullable String group) { return params.isEmpty() && group != null && group.length() > 0 && group.charAt(0) == '<'; } }