Skip to content

Commit

Permalink
Introduce EagerStringFormatting check
Browse files Browse the repository at this point in the history
This new check flag code that can be simplified and/or optimized by
deferring certain string formatting operations.
  • Loading branch information
Stephan202 committed Apr 14, 2024
1 parent 1b69726 commit 0caa780
Show file tree
Hide file tree
Showing 3 changed files with 519 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
package tech.picnic.errorprone.bugpatterns;

import static com.google.errorprone.BugPattern.LinkType.CUSTOM;
import static com.google.errorprone.BugPattern.SeverityLevel.WARNING;
import static com.google.errorprone.BugPattern.StandardTags.PERFORMANCE;
import static com.google.errorprone.BugPattern.StandardTags.SIMPLIFICATION;
import static com.google.errorprone.matchers.Matchers.anyOf;
import static com.google.errorprone.matchers.Matchers.isSubtypeOf;
import static com.google.errorprone.matchers.Matchers.staticMethod;
import static com.google.errorprone.matchers.method.MethodMatchers.instanceMethod;
import static java.util.stream.Collectors.joining;
import static tech.picnic.errorprone.utils.Documentation.BUG_PATTERNS_BASE_URL;

import com.google.auto.service.AutoService;
import com.google.auto.value.AutoValue;
import com.google.common.base.Preconditions;
import com.google.common.base.Verify;
import com.google.common.collect.ImmutableList;
import com.google.errorprone.BugPattern;
import com.google.errorprone.VisitorState;
import com.google.errorprone.annotations.Var;
import com.google.errorprone.bugpatterns.BugChecker;
import com.google.errorprone.bugpatterns.BugChecker.MethodInvocationTreeMatcher;
import com.google.errorprone.fixes.SuggestedFix;
import com.google.errorprone.matchers.Description;
import com.google.errorprone.matchers.Matcher;
import com.google.errorprone.util.ASTHelpers;
import com.sun.source.tree.ExpressionTree;
import com.sun.source.tree.MethodInvocationTree;
import com.sun.source.tree.Tree;
import com.sun.tools.javac.code.Symbol;
import com.sun.tools.javac.code.Symbol.MethodSymbol;
import com.sun.tools.javac.code.Symbol.VarSymbol;
import java.util.Collections;
import java.util.Formattable;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
import tech.picnic.errorprone.utils.SourceCode;

/**
* A {@link BugChecker} that flags {@link String#format(String, Object...)}, {@link
* String#format(Locale, String, Object...)} and {@link String#formatted(Object...)} invocations
* that can be omitted by delegating to another format method.
*/
// XXX: The special-casing of Throwable applies only to SLF4J 1.6.0+; see
// https://www.slf4j.org/faq.html#paramException. That should be documented.
// XXX: Some of the `Matcher`s defined here are also declared by the `Slf4jLogStatement` and
// `RedundantStringConversion` checks. Look into deduplicating them.
// XXX: Should we also simplify e.g. `LOG.error(String.join("sep", arg1, arg2), throwable)`? Perhaps
// that's too obscure.
@AutoService(BugChecker.class)
@BugPattern(
summary = "String formatting can be deferred",
link = BUG_PATTERNS_BASE_URL + "EagerStringFormatting",
linkType = CUSTOM,
severity = WARNING,
tags = {PERFORMANCE, SIMPLIFICATION})
public final class EagerStringFormatting extends BugChecker implements MethodInvocationTreeMatcher {
private static final long serialVersionUID = 1L;
private static final Matcher<ExpressionTree> FORMATTABLE = isSubtypeOf(Formattable.class);
private static final Matcher<ExpressionTree> LOCALE = isSubtypeOf(Locale.class);
private static final Matcher<ExpressionTree> SLF4J_MARKER = isSubtypeOf("org.slf4j.Marker");
private static final Matcher<ExpressionTree> THROWABLE = isSubtypeOf(Throwable.class);
private static final Matcher<ExpressionTree> REQUIRE_NOT_NULL_INVOCATION =
staticMethod().onClass(Objects.class.getCanonicalName()).named("requireNonNull");
private static final Matcher<ExpressionTree> GUAVA_GUARD_INVOCATION =
anyOf(
staticMethod()
.onClass(Preconditions.class.getCanonicalName())
.namedAnyOf("checkArgument", "checkNotNull", "checkState"),
staticMethod()
.onClass(Verify.class.getCanonicalName())
.namedAnyOf("verify", "verifyNotNull"));
private static final Matcher<ExpressionTree> SLF4J_LOGGER_INVOCATION =
instanceMethod()
.onDescendantOf("org.slf4j.Logger")
.namedAnyOf("trace", "debug", "info", "warn", "error");
private static final Matcher<ExpressionTree> STATIC_FORMAT_STRING =
staticMethod().onClass(String.class.getCanonicalName()).named("format");
private static final Matcher<ExpressionTree> INSTANCE_FORMAT_STRING =
instanceMethod().onDescendantOf(String.class.getCanonicalName()).named("formatted");

/** Instantiates a new {@link EagerStringFormatting} instance. */
public EagerStringFormatting() {}

@Override
public Description matchMethodInvocation(MethodInvocationTree tree, VisitorState state) {
Tree parent = state.getPath().getParentPath().getLeaf();
if (!(parent instanceof MethodInvocationTree methodInvocation)) {
/*
* Fast path: this isn't a method invocation whose result is an argument to another method
* invocation. (We assume that this expression isn't redundantly wrapped in parentheses.)
*/
return Description.NO_MATCH;
}

return StringFormatExpression.tryCreate(tree, state)
.map(expr -> analyzeFormatStringContext(expr, methodInvocation, state))
.orElse(Description.NO_MATCH);
}

private Description analyzeFormatStringContext(
StringFormatExpression stringFormat, MethodInvocationTree context, VisitorState state) {
List<? extends ExpressionTree> arguments = context.getArguments();

if (REQUIRE_NOT_NULL_INVOCATION.matches(context, state)) {
if (arguments.size() != 2) {
/* Vacuous validation that string formatting doesn't yield `null`. */
// XXX: Maybe flag this, while we're here?
return Description.NO_MATCH;

Check warning on line 113 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/EagerStringFormatting.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 113 without causing a test to fail

replaced return value with null for analyzeFormatStringContext (covered by 1 tests NullReturnValsMutator)
}

if (stringFormat.arguments().stream()
.anyMatch(EagerStringFormatting::isNonFinalLocalVariable)) {
/*
* The format operation depends on a variable that isn't final or effectively final; moving
* it into a lambda expression would cause a compilation error.
*/
// XXX: Define a custom message for this case?
return describeMatch(context);
}

/*
* Suggest that the string formatting is deferred. Note that moving the format operation into
* a lambda expression will cause a compilation in case any of its arguments aren't
* effectively final.
*/
return describeMatch(context, SuggestedFix.prefixWith(stringFormat.expression(), "() -> "));
}

if (GUAVA_GUARD_INVOCATION.matches(context, state)) {
if (arguments.size() < 2) {

Check warning on line 135 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/EagerStringFormatting.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 135 without causing a test to fail

removed conditional - replaced comparison check with false (covered by 2 tests RemoveConditionalMutator_ORDER_ELSE)
/* Vacuous validation that string formatting doesn't yield `null`. */
// XXX: Maybe flag this, while we're here?
return Description.NO_MATCH;

Check warning on line 138 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/EagerStringFormatting.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 138 without causing a test to fail

replaced return value with null for analyzeFormatStringContext (no tests cover this line NullReturnValsMutator)
}

if (stringFormat.simplifiableFormatString().isEmpty()) {
/* We can't simplify this case; only flag it. */
// XXX: Use a custom message.
return describeMatch(context);
}

// XXX: Collapse into previous statement? (Depends on the extent to which we'll customize
// messages.)
if (arguments.size() > 2) {
/*
* The format string produces a format string itself, or its result is the input to another
* format operation. This is a complex case that we'll only flag.
*/
// XXX: Use a custom message.
return describeMatch(context);
}

return describeMatch(context, stringFormat.suggestFlattening("%s", state));
}

if (SLF4J_LOGGER_INVOCATION.matches(context, state)) {
if (stringFormat.simplifiableFormatString().isEmpty()) {

Check warning on line 162 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/EagerStringFormatting.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 162 without causing a test to fail

removed conditional - replaced equality check with false (covered by 2 tests RemoveConditionalMutator_EQUAL_ELSE)
/* We can't simplify this case; only flag it. */
// XXX: Use a custom message.
return describeMatch(context);

Check warning on line 165 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/EagerStringFormatting.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 165 without causing a test to fail

replaced return value with null for analyzeFormatStringContext (no tests cover this line NullReturnValsMutator)
}

int leftOffset = SLF4J_MARKER.matches(arguments.get(0), state) ? 1 : 0;
int rightOffset = THROWABLE.matches(arguments.get(arguments.size() - 1), state) ? 1 : 0;
if (arguments.size() != leftOffset + 1 + rightOffset) {
/*
* The format string produces a format string itself, or its result is the input to another
* format operation. This is a complex case that we'll only flag.
*/
// XXX: Use a custom message.
return describeMatch(context);
}

return describeMatch(context, stringFormat.suggestFlattening("{}", state));
}

/*
* The string formatting operation does not appear to happen in a context that admits of
* simplification or optimization.
*/
return Description.NO_MATCH;

Check warning on line 186 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/EagerStringFormatting.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 186 without causing a test to fail

replaced return value with null for analyzeFormatStringContext (covered by 1 tests NullReturnValsMutator)
}

private static boolean isNonFinalLocalVariable(Tree tree) {
Symbol symbol = ASTHelpers.getSymbol(tree);
return symbol instanceof VarSymbol

Check warning on line 191 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/EagerStringFormatting.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 191 without causing a test to fail

removed conditional - replaced equality check with true (covered by 2 tests RemoveConditionalMutator_EQUAL_IF)
&& symbol.owner instanceof MethodSymbol
&& !ASTHelpers.isConsideredFinal(symbol);
}

/** Description of a string format expression. */
@AutoValue
abstract static class StringFormatExpression {
/** The full string format expression. */
abstract Tree expression();

/** The string format arguments to be plugged into its format string. */
abstract ImmutableList<ExpressionTree> arguments();

/**
* The constant format string, if it contains only {@code %s} placeholders, and the number of
* said placeholders matches the number of format arguments.
*/
abstract Optional<String> simplifiableFormatString();

private SuggestedFix suggestFlattening(String newPlaceholder, VisitorState state) {
return SuggestedFix.replace(
expression(),
Stream.concat(
Stream.of(deriveFormatStringExpression(newPlaceholder)),
arguments().stream().map(arg -> SourceCode.treeToString(arg, state)))
.collect(joining(", ")));
}

final String deriveFormatStringExpression(String newPlaceholder) {
return SourceCode.toStringConstantExpression(
String.format(
simplifiableFormatString()
.orElseThrow(
() -> new IllegalStateException("Format string cannot be simplified")),

Check warning on line 225 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/EagerStringFormatting.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to a lambda on line 225 without causing a test to fail

replaced return value with null for 1st lambda in deriveFormatStringExpression (no tests cover this line NullReturnValsMutator)
Collections.nCopies(arguments().size(), newPlaceholder).toArray()));
}

private static Optional<StringFormatExpression> tryCreate(
MethodInvocationTree tree, VisitorState state) {
if (INSTANCE_FORMAT_STRING.matches(tree, state)) {

Check warning on line 231 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/EagerStringFormatting.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 231 without causing a test to fail

removed conditional - replaced equality check with false (covered by 2 tests RemoveConditionalMutator_EQUAL_ELSE)
return Optional.of(

Check warning on line 232 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/EagerStringFormatting.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 232 without causing a test to fail

replaced return value with Optional.empty for tryCreate (no tests cover this line EmptyObjectReturnValsMutator)
create(
tree,
ASTHelpers.getReceiver(tree),
ImmutableList.copyOf(tree.getArguments()),
state));
}

if (STATIC_FORMAT_STRING.matches(tree, state)) {
List<? extends ExpressionTree> arguments = tree.getArguments();
int argOffset = LOCALE.matches(arguments.get(0), state) ? 1 : 0;

Check warning on line 242 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/EagerStringFormatting.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 242 without causing a test to fail

removed conditional - replaced equality check with false (covered by 2 tests RemoveConditionalMutator_EQUAL_ELSE)
return Optional.of(
create(
tree,
arguments.get(argOffset),
ImmutableList.copyOf(arguments.subList(argOffset + 1, arguments.size())),
state));
}

return Optional.empty();
}

private static StringFormatExpression create(
Tree expression,
Tree formatString,
ImmutableList<ExpressionTree> arguments,
VisitorState state) {
return new AutoValue_EagerStringFormatting_StringFormatExpression(
expression,
arguments,
Optional.ofNullable(ASTHelpers.constValue(formatString, String.class))
.filter(template -> isSimplifiable(template, arguments, state)));
}

private static boolean isSimplifiable(
String formatString, ImmutableList<ExpressionTree> arguments, VisitorState state) {
if (arguments.stream().anyMatch(arg -> FORMATTABLE.matches(arg, state))) {

Check warning on line 268 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/EagerStringFormatting.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 268 without causing a test to fail

removed conditional - replaced equality check with false (covered by 2 tests RemoveConditionalMutator_EQUAL_ELSE)

Check warning on line 268 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/EagerStringFormatting.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to a lambda on line 268 without causing a test to fail

replaced boolean return with false for 1st lambda in isSimplifiable (covered by 2 tests BooleanFalseReturnValsMutator)
/* `Formattable` arguments can have arbitrary format semantics. */
return false;

Check warning on line 270 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/EagerStringFormatting.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 270 without causing a test to fail

replaced boolean return with true for isSimplifiable (no tests cover this line BooleanTrueReturnValsMutator)
}

@Var int placeholderCount = 0;
for (int p = formatString.indexOf('%'); p != -1; p = formatString.indexOf('%', p + 2)) {

Check warning on line 274 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/EagerStringFormatting.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 274 without causing a test to fail

swapped parameters 1 and 2 in call to indexOf (covered by 2 tests ParamSwapMutator)
if (p == formatString.length() - 1) {

Check warning on line 275 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/EagerStringFormatting.java

View workflow job for this annotation

GitHub Actions / pitest

2 different changes can be made to line 275 without causing a test to fail

Replaced integer subtraction with addition (covered by 2 tests MathMutator) removed conditional - replaced equality check with false (covered by 2 tests RemoveConditionalMutator_EQUAL_ELSE)
/* Malformed format string with trailing `%`. */
return false;

Check warning on line 277 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/EagerStringFormatting.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 277 without causing a test to fail

replaced boolean return with true for isSimplifiable (no tests cover this line BooleanTrueReturnValsMutator)
}

char modifier = formatString.charAt(p + 1);
if (modifier == 's') {
placeholderCount++;
} else if (modifier != '%') {

Check warning on line 283 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/EagerStringFormatting.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 283 without causing a test to fail

removed conditional - replaced equality check with false (covered by 2 tests RemoveConditionalMutator_EQUAL_ELSE)
/* Only `%s` and `%%` (a literal `%`) are supported. */
return false;
}
}

return placeholderCount == arguments.size();

Check warning on line 289 in error-prone-contrib/src/main/java/tech/picnic/errorprone/bugpatterns/EagerStringFormatting.java

View workflow job for this annotation

GitHub Actions / pitest

A change can be made to line 289 without causing a test to fail

replaced boolean return with true for isSimplifiable (covered by 2 tests BooleanTrueReturnValsMutator)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@
/** A {@link BugChecker} that flags SLF4J usages that are likely to be in error. */
// XXX: The special-casing of Throwable applies only to SLF4J 1.6.0+; see
// https://www.slf4j.org/faq.html#paramException. That should be documented.
// XXX: Also simplify `LOG.error(String.format("Something %s", arg), throwable)`.
// XXX: Also simplify `LOG.error(String.join("sep", arg1, arg2), throwable)`? Perhaps too obscure.
// XXX: Write a similar checker for Spring RestTemplates, String.format and friends, Guava
// preconditions, ...
@AutoService(BugChecker.class)
Expand Down
Loading

0 comments on commit 0caa780

Please sign in to comment.