Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce OptionalOrElse check #1024

Merged
merged 4 commits into from
Mar 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package tech.picnic.errorprone.bugpatterns;

import static com.google.errorprone.BugPattern.LinkType.NONE;
import static com.google.errorprone.BugPattern.SeverityLevel.WARNING;
import static com.google.errorprone.BugPattern.StandardTags.PERFORMANCE;
import static com.google.errorprone.matchers.Matchers.instanceMethod;
import static com.google.errorprone.matchers.Matchers.staticMethod;
import static java.util.stream.Collectors.joining;

import com.google.auto.service.AutoService;
import com.google.common.collect.Iterables;
import com.google.errorprone.BugPattern;
import com.google.errorprone.VisitorState;
import com.google.errorprone.bugpatterns.BugChecker;
import com.google.errorprone.bugpatterns.BugChecker.MethodInvocationTreeMatcher;
import com.google.errorprone.fixes.SuggestedFix;
import com.google.errorprone.fixes.SuggestedFixes;
import com.google.errorprone.matchers.Description;
import com.google.errorprone.matchers.Matcher;
import com.google.errorprone.refaster.Refaster;
import com.google.errorprone.util.ASTHelpers;
import com.sun.source.tree.ExpressionTree;
import com.sun.source.tree.IdentifierTree;
import com.sun.source.tree.LiteralTree;
import com.sun.source.tree.MemberSelectTree;
import com.sun.source.tree.MethodInvocationTree;
import java.util.Optional;
import java.util.function.Supplier;
import tech.picnic.errorprone.utils.SourceCode;

/**
* A {@link BugChecker} that flags arguments to {@link Optional#orElse(Object)} that should be
* deferred using {@link Optional#orElseGet(Supplier)}.
*
* <p>The suggested fix assumes that the argument to {@code orElse} does not have side effects. If
* it does, the suggested fix changes the program's semantics. Such fragile code must instead be
* refactored such that the side-effectful code does not appear accidental.
*/
// XXX: Consider also implementing the inverse, in which `.orElseGet(() -> someConstant)` is
// flagged.
// XXX: Once the `MethodReferenceUsageCheck` becomes generally usable, consider leaving the method
// reference cleanup to that check, and express the remainder of the logic in this class using a
// Refaster template, i.c.w. a `@Matches` constraint that implements the `requiresComputation`
// logic.
@AutoService(BugChecker.class)
@BugPattern(
summary =
"""
Prefer `Optional#orElseGet` over `Optional#orElse` if the fallback requires additional \
computation""",
linkType = NONE,
severity = WARNING,
tags = PERFORMANCE)
public final class OptionalOrElse extends BugChecker implements MethodInvocationTreeMatcher {
private static final long serialVersionUID = 1L;
private static final Matcher<ExpressionTree> OPTIONAL_OR_ELSE_METHOD =
instanceMethod().onExactClass(Optional.class.getCanonicalName()).namedAnyOf("orElse");
// XXX: Also exclude invocations of `@Placeholder`-annotated methods.
private static final Matcher<ExpressionTree> REFASTER_METHOD =
staticMethod().onClass(Refaster.class.getCanonicalName());

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

@Override
public Description matchMethodInvocation(MethodInvocationTree tree, VisitorState state) {
if (!OPTIONAL_OR_ELSE_METHOD.matches(tree, state)) {
return Description.NO_MATCH;
}

ExpressionTree argument = Iterables.getOnlyElement(tree.getArguments());
if (!requiresComputation(argument) || REFASTER_METHOD.matches(argument, state)) {
return Description.NO_MATCH;
}

/*
* We have a match. Construct the method reference or lambda expression to be passed to the
* replacement `#orElseGet` invocation.
*/
String newArgument =
tryMethodReferenceConversion(argument, state)
.orElseGet(() -> "() -> " + SourceCode.treeToString(argument, state));

/* Construct the suggested fix, replacing the method invocation and its argument. */
SuggestedFix fix =
SuggestedFix.builder()
.merge(SuggestedFixes.renameMethodInvocation(tree, "orElseGet", state))
.replace(argument, newArgument)
.build();

return describeMatch(tree, fix);
}

/**
* Tells whether the given expression contains anything other than a literal or a (possibly
* dereferenced) variable or constant.
*/
private static boolean requiresComputation(ExpressionTree tree) {
return !(tree instanceof IdentifierTree
|| tree instanceof LiteralTree
|| (tree instanceof MemberSelectTree memberSelect
&& !requiresComputation(memberSelect.getExpression()))
|| ASTHelpers.constValue(tree) != null);
}

/** Returns the nullary method reference matching the given expression, if any. */
private static Optional<String> tryMethodReferenceConversion(
ExpressionTree tree, VisitorState state) {
if (!(tree instanceof MethodInvocationTree methodInvocation)) {
return Optional.empty();
}

if (!methodInvocation.getArguments().isEmpty()) {
return Optional.empty();
}

if (!(methodInvocation.getMethodSelect() instanceof MemberSelectTree memberSelect)) {
return Optional.empty();
}

if (requiresComputation(memberSelect.getExpression())) {
return Optional.empty();
}

return Optional.of(
SourceCode.treeToString(memberSelect.getExpression(), state)
+ "::"
+ (methodInvocation.getTypeArguments().isEmpty()
? ""
: methodInvocation.getTypeArguments().stream()
.map(arg -> SourceCode.treeToString(arg, state))
.collect(joining(",", "<", ">")))
+ memberSelect.getIdentifier());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ Optional<R> after(Optional<T> optional, Function<? super S, Optional<? extends R
/** Prefer {@link Optional#or(Supplier)} over more verbose alternatives. */
static final class OptionalOrOtherOptional<T> {
@BeforeTemplate
@SuppressWarnings("NestedOptionals" /* Auto-fix for the `NestedOptionals` check. */)
@SuppressWarnings("NestedOptionals")
Optional<T> before(Optional<T> optional1, Optional<T> optional2) {
// XXX: Note that rewriting the first and third variant will change the code's behavior if
// `optional2` has side-effects.
Expand All @@ -386,9 +386,13 @@ Optional<T> after(Optional<T> optional1, Optional<T> optional2) {
*/
static final class OptionalIdentity<T> {
@BeforeTemplate
@SuppressWarnings("NestedOptionals")
Optional<T> before(Optional<T> optional, Comparator<? super T> comparator) {
return Refaster.anyOf(
optional.or(Refaster.anyOf(() -> Optional.empty(), Optional::empty)),
optional
.map(Optional::of)
.orElseGet(Refaster.anyOf(() -> Optional.empty(), Optional::empty)),
optional.stream().findFirst(),
optional.stream().findAny(),
optional.stream().min(comparator),
Expand Down Expand Up @@ -442,9 +446,7 @@ Optional<? extends T> after(Optional<S> optional, Function<? super S, ? extends
static final class OptionalStream<T> {
@BeforeTemplate
Stream<T> before(Optional<T> optional) {
return Refaster.anyOf(
optional.map(Stream::of).orElse(Stream.empty()),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to align my thoughts on this.

We are making an assumption that (the new) BugChecker will flag this, so no need for refaster rule, which makes sense 👍

(This is the part where I need help in)
However, doesn't this conflict with the assumption here that we don't rely on the existence of NestedOptional BugChecker ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question @mohamedsamehsalah. This is explained by a crucial difference between NestedOptionals and OptionalOrElse: the latter has a SuggestedFix (which then obviates the need for a corresponding Refaster rule), while the former only flags issues, but doesn't provide a fix (which means that there's still value in providing a Refaster rule for a subset of violations).

optional.map(Stream::of).orElseGet(Stream::empty));
return optional.map(Stream::of).orElseGet(Stream::empty);
}

@AfterTemplate
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package tech.picnic.errorprone.bugpatterns;

import com.google.errorprone.BugCheckerRefactoringTestHelper;
import com.google.errorprone.BugCheckerRefactoringTestHelper.TestMode;
import com.google.errorprone.CompilationTestHelper;
import org.junit.jupiter.api.Test;

final class OptionalOrElseTest {
@Test
void identification() {
CompilationTestHelper.newInstance(OptionalOrElse.class, getClass())
.addSourceLines(
"A.java",
"import com.google.errorprone.refaster.Refaster;",
"import java.util.Optional;",
"",
"class A {",
" private final Optional<Object> optional = Optional.empty();",
" private final String string = optional.toString();",
"",
" void m() {",
" Optional.empty().orElse(null);",
" optional.orElse(null);",
" optional.orElse(\"constant\");",
" optional.orElse(\"constant\" + 0);",
" optional.orElse(Boolean.TRUE);",
" optional.orElse(string);",
" optional.orElse(this.string);",
" optional.orElse(Refaster.anyOf(\"constant\", \"another\"));",
"",
" // BUG: Diagnostic contains:",
" Optional.empty().orElse(string + \"constant\");",
" // BUG: Diagnostic contains:",
" optional.orElse(string + \"constant\");",
" // BUG: Diagnostic contains:",
" optional.orElse(\"constant\".toString());",
" // BUG: Diagnostic contains:",
" optional.orElse(string.toString());",
" // BUG: Diagnostic contains:",
" optional.orElse(this.string.toString());",
" // BUG: Diagnostic contains:",
" optional.orElse(String.valueOf(42));",
" // BUG: Diagnostic contains:",
" optional.orElse(string.toString().length());",
" // BUG: Diagnostic contains:",
" optional.orElse(\"constant\".equals(string));",
" // BUG: Diagnostic contains:",
" optional.orElse(string.equals(string));",
" // BUG: Diagnostic contains:",
" optional.orElse(this.string.equals(string));",
" // BUG: Diagnostic contains:",
" optional.orElse(foo());",
" // BUG: Diagnostic contains:",
" optional.orElse(this.foo());",
" // BUG: Diagnostic contains:",
" optional.orElse(new Object() {});",
" // BUG: Diagnostic contains:",
" optional.orElse(new int[0].length);",
" }",
"",
" private <T> T foo() {",
" return null;",
" }",
"}")
.doTest();
}

@Test
void replacement() {
BugCheckerRefactoringTestHelper.newInstance(OptionalOrElse.class, getClass())
.addInputLines(
"A.java",
"import java.util.Optional;",
"",
"class A {",
" private final Optional<Object> optional = Optional.empty();",
" private final String string = optional.toString();",
"",
" void m() {",
" optional.orElse(string + \"constant\");",
" optional.orElse(\"constant\".toString());",
" optional.orElse(string.toString());",
" optional.orElse(this.string.toString());",
" optional.orElse(String.valueOf(42));",
" optional.orElse(string.toString().length());",
" optional.orElse(string.equals(string));",
" optional.orElse(foo());",
" optional.orElse(this.<Number>foo());",
" optional.orElse(this.<String, Integer>bar());",
" optional.orElse(new Object() {});",
" optional.orElse(new int[0].length);",
" }",
"",
" private <T> T foo() {",
" return null;",
" }",
"",
" private <S, T> T bar() {",
" return null;",
" }",
"}")
Comment on lines +91 to +101
Copy link
Contributor

@mohamedsamehsalah mohamedsamehsalah Feb 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I do this

Suggested change
" optional.orElse(new int[0].length);",
" }",
"",
" private <T> T foo() {",
" return null;",
" }",
"",
" private <S, T> T bar() {",
" return null;",
" }",
"}")
" optional.orElse(new int[0].length);",
" optional.orElse(B.NUMBER);",
" optional.orElse(B.getNumber());",
" }",
"",
" private <T> T foo() {",
" return null;",
" }",
"",
" private <S, T> T bar() {",
" return null;",
" }",
"",
" class B {",
" static final int NUMBER = 1;",
"",
" static String getNumber() {",
" return \"number\";",
" }",
" }",
"}")

Shouldn't we expect optional.orElse(B.NUMBER) to be optional.orElseGet(() -> B.NUMBER) ?

This is what I get instead
Screenshot 2024-02-16 at 17 03 47

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intended :). The idea here is that field dereferencing (foo.bar.baz etc.) is very efficient (conceptually: just some pointer dereferencing), so there's no point in deferring that work.

.addOutputLines(
"A.java",
"import java.util.Optional;",
"",
"class A {",
" private final Optional<Object> optional = Optional.empty();",
" private final String string = optional.toString();",
"",
" void m() {",
" optional.orElseGet(() -> string + \"constant\");",
" optional.orElseGet(\"constant\"::toString);",
" optional.orElseGet(string::toString);",
" optional.orElseGet(this.string::toString);",
" optional.orElseGet(() -> String.valueOf(42));",
" optional.orElseGet(() -> string.toString().length());",
" optional.orElseGet(() -> string.equals(string));",
" optional.orElseGet(() -> foo());",
" optional.orElseGet(this::<Number>foo);",
" optional.orElseGet(this::<String, Integer>bar);",
" optional.orElseGet(() -> new Object() {});",
" optional.orElseGet(() -> new int[0].length);",
" }",
"",
" private <T> T foo() {",
" return null;",
" }",
"",
" private <S, T> T bar() {",
" return null;",
" }",
"}")
.doTest(TestMode.TEXT_MATCH);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,12 @@ ImmutableSet<Optional<String>> testOptionalIdentity() {
return ImmutableSet.of(
Optional.of("foo").or(() -> Optional.empty()),
Optional.of("bar").or(Optional::empty),
Optional.of("baz").stream().findFirst(),
Optional.of("qux").stream().findAny(),
Optional.of("quux").stream().min(String::compareTo),
Optional.of("quuz").stream().max(String::compareTo));
Optional.of("baz").map(Optional::of).orElseGet(() -> Optional.empty()),
Optional.of("qux").map(Optional::of).orElseGet(Optional::empty),
Optional.of("quux").stream().findFirst(),
Optional.of("quuz").stream().findAny(),
Optional.of("corge").stream().min(String::compareTo),
Optional.of("grault").stream().max(String::compareTo));
}

ImmutableSet<Optional<String>> testOptionalFilter() {
Expand All @@ -136,9 +138,7 @@ Optional<String> testOptionalMap() {
return Optional.of(1).stream().map(String::valueOf).findAny();
}

ImmutableSet<Stream<String>> testOptionalStream() {
return ImmutableSet.of(
Optional.of("foo").map(Stream::of).orElse(Stream.empty()),
Optional.of("bar").map(Stream::of).orElseGet(Stream::empty));
Stream<String> testOptionalStream() {
return Optional.of("foo").map(Stream::of).orElseGet(Stream::empty);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ ImmutableSet<Optional<String>> testOptionalIdentity() {
Optional.of("baz"),
Optional.of("qux"),
Optional.of("quux"),
Optional.of("quuz"));
Optional.of("quuz"),
Optional.of("corge"),
Optional.of("grault"));
}

ImmutableSet<Optional<String>> testOptionalFilter() {
Expand All @@ -132,7 +134,7 @@ Optional<String> testOptionalMap() {
return Optional.of(1).map(String::valueOf);
}

ImmutableSet<Stream<String>> testOptionalStream() {
return ImmutableSet.of(Optional.of("foo").stream(), Optional.of("bar").stream());
Stream<String> testOptionalStream() {
return Optional.of("foo").stream();
}
}
9 changes: 9 additions & 0 deletions integration-tests/checkstyle-expected-changes.patch
Original file line number Diff line number Diff line change
Expand Up @@ -53686,6 +53686,15 @@
final ModuleFactory moduleFactory = TestUtil.getPackageObjectFactory();

final Path path = Paths.get(XdocUtil.DIRECTORY_PATH + "/config.xml");
@@ -1081,7 +1083,7 @@ public class XdocsPagesTest {
Optional.ofNullable(field)
.map(nonNullField -> nonNullField.getAnnotation(XdocsPropertyType.class))
.map(propertyType -> propertyType.value().getDescription())
- .orElse(fieldClass.getSimpleName());
+ .orElseGet(fieldClass::getSimpleName);
final String expectedValue =
getModulePropertyExpectedValue(sectionName, propertyName, field, fieldClass, instance);

Comment on lines +53689 to +53697
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this change the integration test passes.

@@ -1364,7 +1366,7 @@ public class XdocsPagesTest {
final Object[] array = (Object[]) value;
valuesStream = Arrays.stream(array);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ private static Description augmentDescription(
"Refaster Rule",
description.getLink(),
String.join(": ", description.checkName, description.getRawMessage()))
.overrideSeverity(severityOverride.orElse(description.severity()))
.overrideSeverity(severityOverride.orElseGet(description::severity))
.addAllFixes(description.fixes)
.build();
}
Expand Down