[javac plugin] Add compile-time checks for unsafe or incorrect coroutine usage (#8289)

CoroutineYieldInLoopDetector

This checks for while loops where coroutines are in scope but without calling a blocking method on at least one of those coroutines:

```
drivetrain.run(theCoroutine -> {
  while (drivetrain.getDistance() < 10) { // ERROR: "Missing call to `theCoroutine.yield()` inside loop"
    drivetrain.setSpeed(1);
  }
});
```

Note that, because we assume most looping constructs in commands will use while loops, we don't check for-loops, for-each loops, or do-while loops.

This check can be disabled with `@SuppressWarnings("CoroutineYieldInLoop")`

CodeAfterCoroutineParkDetector

Essentially acts like the Java compiler's check for code after a while (true) loop, but for coroutine.park():

```
drivetrain.run(theCoroutine -> {
  drivetrain.setSpeed(1.0);
  theCoroutine.park();
  drivetrain.setSpeed(0.0); // ERROR: "Unreachable statement: `theCoroutine.park()` will never exit"
});
```

This check can be disabled with `@SuppressWarnings("CodeAfterCoroutinePark")`

IncorrectCoroutineUseDetector

Checks for usage of captured (outer) coroutine parameters and assignments to fields.

```
drivetrain.run(outer -> {
  outer.await(arm.run(inner -> {
    outer.yield(); // ERROR: "Coroutine `outer` may not be in scope. Consider using `inner`"
  }))
});
```

This check can be disabled with `@SuppressWarnings("CoroutineMayNotBeInScope")`

```
private Coroutine coroutineField;
drivetrain.run(co -> coroutineField = co); // ERROR: "Captured coroutines may not be stored in fields"
```

This check can be disabled with `@SuppressWarnings("CoroutineCapture")`
This commit is contained in:
Sam Carlberg
2025-11-01 20:27:08 -04:00
committed by GitHub
parent bc44ced506
commit 8992cf7081
13 changed files with 1796 additions and 14 deletions

View File

@@ -0,0 +1,86 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.javacplugin;
import com.sun.source.tree.BlockTree;
import com.sun.source.tree.CompilationUnitTree;
import com.sun.source.tree.EmptyStatementTree;
import com.sun.source.tree.ExpressionStatementTree;
import com.sun.source.tree.IdentifierTree;
import com.sun.source.tree.MemberSelectTree;
import com.sun.source.tree.MethodInvocationTree;
import com.sun.source.tree.StatementTree;
import com.sun.source.util.JavacTask;
import com.sun.source.util.TreeScanner;
import com.sun.source.util.Trees;
import javax.lang.model.element.VariableElement;
import javax.tools.Diagnostic;
/**
* Detects any statements after a call to {@code coroutine.park()} and labels them as unreachable
* code, similar to a {@code while (true)} statement.
*/
public class CodeAfterCoroutineParkDetector extends CoroutineBasedDetector {
public static final String SUPPRESSION_KEY = "CodeAfterCoroutinePark";
public CodeAfterCoroutineParkDetector(JavacTask task) {
super(task);
}
@Override
protected TreeScanner<?, ?> createScanner(CompilationUnitTree compilationUnit) {
return new Scanner(compilationUnit);
}
private final class Scanner extends TreeScanner<Void, Void> {
private final CompilationUnitTree m_root;
private final Trees m_trees = Trees.instance(m_task);
Scanner(CompilationUnitTree compilationUnit) {
m_root = compilationUnit;
}
@Override
public Void visitBlock(BlockTree node, Void param) {
var path = m_trees.getPath(m_root, node);
if (Suppressions.hasSuppression(m_trees, path, SUPPRESSION_KEY)) {
// Error is suppressed for this block, don't bother checking
return super.visitBlock(node, param);
}
MethodInvocationTree parkInvocation = null;
for (StatementTree statement : node.getStatements()) {
if (statement instanceof EmptyStatementTree) {
// skip empty statements; someone could have just added an extra semicolon by accident
continue;
}
if (parkInvocation != null) {
m_trees.printMessage(
Diagnostic.Kind.ERROR,
"Unreachable statement: `" + parkInvocation + "` will never exit",
statement,
m_root);
break;
}
if (statement instanceof ExpressionStatementTree est
&& est.getExpression() instanceof MethodInvocationTree mit
&& mit.getMethodSelect() instanceof MemberSelectTree ms
&& ms.getIdentifier().contentEquals("park")
&& ms.getExpression() instanceof IdentifierTree id) {
var idPath = m_trees.getPath(m_root, id);
var identifierElement = m_trees.getElement(idPath);
if (identifierElement instanceof VariableElement ve
&& m_task.getTypes().isSameType(m_coroutineType, ve.asType())) {
parkInvocation = mit;
}
}
}
return super.visitBlock(node, param);
}
}
}

View File

@@ -0,0 +1,56 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.javacplugin;
import com.sun.source.tree.CompilationUnitTree;
import com.sun.source.util.JavacTask;
import com.sun.source.util.TaskEvent;
import com.sun.source.util.TaskListener;
import com.sun.source.util.TreeScanner;
import java.util.HashSet;
import java.util.Set;
import javax.lang.model.type.TypeMirror;
/**
* Base class for detectors that scan for code that uses coroutines. Subclasses should provide a
* {@link TreeScanner} class that implements the logic for the detector.
*/
public abstract class CoroutineBasedDetector implements TaskListener {
protected final JavacTask m_task;
private final Set<CompilationUnitTree> m_visitedCUs = new HashSet<>();
/**
* The type of the Coroutine class. This is null if the commands v3 library is not on the
* classpath, or if the field is read before the Java compiler finishes the analyze phase. Custom
* scanners are only used if this is non-null; scanner code can safely assume that it is present.
*/
protected TypeMirror m_coroutineType;
protected CoroutineBasedDetector(JavacTask task) {
m_task = task;
}
@Override
public void finished(TaskEvent taskEvent) {
var compilationUnit = taskEvent.getCompilationUnit();
if (taskEvent.getKind() == TaskEvent.Kind.ANALYZE && m_visitedCUs.add(compilationUnit)) {
m_coroutineType = getCoroutineType();
if (m_coroutineType == null) {
// Not using the commands library; nothing to scan for
return;
}
compilationUnit.accept(createScanner(compilationUnit), null);
}
}
protected abstract TreeScanner<?, ?> createScanner(CompilationUnitTree compilationUnit);
private TypeMirror getCoroutineType() {
var te = m_task.getElements().getTypeElement("org.wpilib.commands3.Coroutine");
return te == null ? null : te.asType();
}
}

View File

@@ -0,0 +1,303 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.javacplugin;
import com.sun.source.tree.CompilationUnitTree;
import com.sun.source.tree.IdentifierTree;
import com.sun.source.tree.LambdaExpressionTree;
import com.sun.source.tree.MemberSelectTree;
import com.sun.source.tree.MethodInvocationTree;
import com.sun.source.tree.MethodTree;
import com.sun.source.tree.WhileLoopTree;
import com.sun.source.util.JavacTask;
import com.sun.source.util.TreeScanner;
import com.sun.source.util.Trees;
import java.util.ArrayList;
import java.util.List;
import javax.lang.model.element.Element;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.VariableElement;
import javax.tools.Diagnostic;
/**
* Checks for {@code while} loops inside methods or lambda functions that accept coroutine
* arguments. If a loop does not call {@code yield()} on one of the most local coroutine objects, a
* compiler error will be emitted for that loop element. This check cannot be silenced.
*/
// Note: cannot be silenced because annotations cannot be placed on loops.
// This is not legal Java:
// @SuppressWarnings("UnsafeCoroutineUsage")
// while (true) { ... }
// Placing it at a higher level (lambda or method declaration) would silence ALL unsafe usage in
// that expression; it's impossible to do on a case-by-case basis.
public class CoroutineYieldInLoopDetector extends CoroutineBasedDetector {
public static final String SUPPRESSION_KEY = "CoroutineYieldInLoop";
public CoroutineYieldInLoopDetector(JavacTask task) {
super(task);
}
@Override
protected TreeScanner<?, ?> createScanner(CompilationUnitTree compilationUnit) {
return new Scanner(compilationUnit);
}
/**
* Tracks the state of a while loop while traversing the AST. These are initially created when
* encountering a method or lambda function declaration, after checking that the method or lambda
* accepts at least one Coroutine argument. All Coroutine arguments on the method or lambda
* function will be added to m_availableCoroutines. If a nested lambda function is found that
* accepts coroutines, then a new state will be created for that lambda and its coroutine
* arguments will be the ones that need to be yielded on.
*
* <p>If a `while` loop is not encountered while further traversing the tree, then the initial
* object will not be modified and will be discarded, unused. But if a `while` loop _is_
* encountered, then m_loop will be assigned to that loop element and further traversal will
* occur. Any calls to `yield()` on one of the coroutine arguments declared by the enclosing
* method or lambda function will be detected and added to m_yieldCalls. Any `while` loops
* encountered while m_loop is set are child loops, and will be parsed standalone and given new
* LoopState objects, which will then be added to m_children. Error reporting is only done by the
* root state object once its entire AST has been traversed, to ensure that inner loops do not
* report errors first and appearing out of order in the compiler output.
*
* <p>Note: this is a mutable type so that a single object may be updated as the tree traversal
* reaches points of interest (lambda definition, loop declarations, and so on) and have its state
* checked at a higher level when inner AST traversal is finished.
*/
private static final class LoopState {
/** The loop element being tracked. */
WhileLoopTree m_loop;
/**
* All discovered calls to Coroutine.yield(). Only applies to calls to coroutines in
* m_availableCoroutines.
*/
final List<Element> m_yieldCalls = new ArrayList<>();
/**
* The set of the most local coroutine arguments. eg for nested lambdas, the coroutine arguments
* to the innermost lambda will be present, but none of the coroutine arguments from any of the
* enclosing lambdas.
*/
final List<VariableElement> m_availableCoroutines = new ArrayList<>();
/**
* All `while` loops nested inside m_loop. Only applies to direct children; loops in
* conditionals, switch blocks, and the like will be present, but not loops in other nested
* loops, nor loops declared inside a lambda inside a loop.
*/
final List<LoopState> m_children = new ArrayList<>();
}
private final class Scanner extends TreeScanner<LoopState, LoopState> {
private final CompilationUnitTree m_root;
private final Trees m_trees;
Scanner(CompilationUnitTree compilationUnit) {
m_root = compilationUnit;
m_trees = Trees.instance(m_task);
}
@Override
public LoopState visitMethod(MethodTree node, LoopState loopState) {
var path = m_trees.getPath(m_root, node);
var el = m_trees.getElement(path);
if (!(el instanceof ExecutableElement ex)) {
return super.visitMethod(node, loopState);
}
// Scan parameters to see if any are coroutines
List<? extends VariableElement> coroutineParameters =
ex.getParameters().stream()
.filter(
parameter -> m_task.getTypes().isSameType(parameter.asType(), m_coroutineType))
.toList();
if (coroutineParameters.isEmpty()) {
// This method does not take coroutine arguments
return super.visitMethod(node, loopState);
}
// Note: We only want to allow yield() calls to the MOST local coroutines possible, so nested
// methods or lambdas don't accidentally call yield on the coroutine of an outer command
// (which would be a RuntimeError, since that coroutine would not be mounted).
// Therefore, we do not copy the available coroutines from the parent state to the new
// child state.
var localState = new LoopState();
localState.m_availableCoroutines.addAll(coroutineParameters);
if (loopState != null) {
loopState.m_children.add(localState);
}
return super.visitMethod(node, localState);
}
@Override
public LoopState visitLambdaExpression(LambdaExpressionTree node, LoopState loopState) {
// Scan parameters to see if any are coroutines
List<? extends VariableElement> coroutineParameters =
node.getParameters().stream()
.filter(
parameter -> {
var paramPath = m_trees.getPath(m_root, parameter.getType());
var paramTypeMirror = m_trees.getTypeMirror(paramPath);
return m_task.getTypes().isSameType(paramTypeMirror, m_coroutineType);
})
.map(
variableTree -> {
return (VariableElement)
m_trees.getElement(m_trees.getPath(m_root, variableTree));
})
.toList();
if (coroutineParameters.isEmpty()) {
// This lambda function does not take coroutine arguments
return super.visitLambdaExpression(node, loopState);
}
// Note: we only want to allow yield() calls to the MOST local coroutines possible, so nested
// methods or lambdas don't accidentally call yield on the coroutine of an outer command
// (which would be a RuntimeError, since that coroutine would not be mounted).
// Therefore, we do not copy the available coroutines from the parent state to the new
// child state.
var localState = new LoopState();
localState.m_availableCoroutines.addAll(coroutineParameters);
if (loopState != null) {
loopState.m_children.add(localState);
}
return super.visitLambdaExpression(node, localState);
}
@Override
public LoopState visitWhileLoop(WhileLoopTree node, LoopState loopState) {
if (loopState == null) {
// Not inside a coroutine-accepting method or lambda function; bail
return super.visitWhileLoop(node, null);
}
var path = m_trees.getPath(m_root, node);
if (Suppressions.hasSuppression(m_trees, path, SUPPRESSION_KEY)) {
// Error is suppressed in this context, don't bother checking
return super.visitWhileLoop(node, loopState);
}
if (loopState.m_loop == null) {
loopState.m_loop = node;
var result = super.visitWhileLoop(node, loopState);
printErrors(loopState);
return result;
} else {
// Nested loop; split off a new child with the same available coroutines
var localState = new LoopState();
localState.m_loop = node;
localState.m_availableCoroutines.addAll(loopState.m_availableCoroutines);
loopState.m_children.add(localState);
// Don't print errors now - we'll handle that when we finish the parent loop
// Otherwise, errors would be printed by the innermost loops first and appear out of order,
// which is confusing
return super.visitWhileLoop(node, localState);
}
}
private void printErrors(LoopState state) {
if (state == null) {
return;
}
if (state.m_availableCoroutines.isEmpty() || state.m_loop == null) {
// Technically, this state should never be reachable.
// But to prevent errors in the compiler plugin from preventing user code from building,
// we silently ignore this bad state.
return;
}
if (state.m_yieldCalls.isEmpty()) {
m_trees.printMessage(
Diagnostic.Kind.ERROR, buildErrorMessageForLoop(state), state.m_loop, m_root);
}
// Recurse over children
for (var childLoop : state.m_children) {
printErrors(childLoop);
}
}
private CharSequence buildErrorMessageForLoop(LoopState state) {
StringBuilder messageBuilder = new StringBuilder(64).append("Missing call to ");
var suggestedFunctionCalls =
state.m_availableCoroutines.stream()
.map(e -> "`" + e.getSimpleName().toString() + ".yield()`")
.toList();
// Join function calls with commas, and inserting an "or" before the final item
// No commas are used for lists of 2 items or fewer
switch (suggestedFunctionCalls.size()) {
case 1 -> messageBuilder.append(suggestedFunctionCalls.get(0));
case 2 ->
messageBuilder
.append(suggestedFunctionCalls.get(0))
.append(" or ")
.append(suggestedFunctionCalls.get(1));
default -> {
for (int i = 0; i < suggestedFunctionCalls.size(); i++) {
messageBuilder.append(suggestedFunctionCalls.get(i));
if (i < suggestedFunctionCalls.size() - 2) {
// early items
messageBuilder.append(", ");
} else if (i == suggestedFunctionCalls.size() - 2) {
// penultimate item
messageBuilder.append(", or ");
} else {
// last item - append nothing
}
}
}
}
messageBuilder.append(" inside loop");
return messageBuilder;
}
@Override
public LoopState visitMethodInvocation(MethodInvocationTree node, LoopState loopState) {
if (loopState == null) {
// not inside a coroutine-accepting method or lambda function; bail
return super.visitMethodInvocation(node, null);
}
var sel = node.getMethodSelect();
if (sel instanceof MemberSelectTree ms && isPermittedCoroutineFunction(ms)) {
// calling a method on something (may not be a coroutine object) with the same name as a
// permitted coroutine function name
var recv = ms.getExpression();
if (recv instanceof IdentifierTree id) {
var idPath = m_trees.getPath(m_root, id);
var el = m_trees.getElement(idPath);
if (el instanceof VariableElement ve && loopState.m_availableCoroutines.contains(ve)) {
// definitely calling yield on a local (method or lambda argument) coroutine object
loopState.m_yieldCalls.add(el);
}
}
}
return super.visitMethodInvocation(node, loopState);
}
}
private boolean isPermittedCoroutineFunction(MemberSelectTree tree) {
// Loops MUST either call yield() or a blocking coroutine function like await() or waitFor()
// that call yield() internally. Nonblocking coroutine functions like fork() aren't enough.
var identifier = tree.getIdentifier();
return identifier.contentEquals("yield")
|| identifier.contentEquals("park")
|| identifier.contentEquals("wait")
|| identifier.contentEquals("waitUntil")
|| identifier.contentEquals("await")
|| identifier.contentEquals("awaitAll")
|| identifier.contentEquals("awaitAny");
}
}

View File

@@ -0,0 +1,242 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.javacplugin;
import com.sun.source.tree.AssignmentTree;
import com.sun.source.tree.CompilationUnitTree;
import com.sun.source.tree.IdentifierTree;
import com.sun.source.tree.LambdaExpressionTree;
import com.sun.source.tree.MemberSelectTree;
import com.sun.source.tree.MethodInvocationTree;
import com.sun.source.util.JavacTask;
import com.sun.source.util.TreeScanner;
import com.sun.source.util.Trees;
import java.util.ArrayList;
import java.util.List;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.VariableElement;
import javax.tools.Diagnostic;
/**
* Detects usage of an incorrect coroutine object. For example, nesting commands with multiple
* coroutines in scope need to use the most local coroutine object and not reference external ones.
*
* <pre>{@code
* mech.run(outerCoroutine -> {
* mech.run(innerCoroutine -> {
* outerCoroutine.yield(); // ERROR
* })
* })
* }</pre>
*
* <p>Additional checks for escaping coroutines are also performed:
*
* <pre>{@code
* private Coroutine coroutineField;
*
* mech.run(coroutine -> coroutineField = coroutine); // ERROR
* }</pre>
*/
public class IncorrectCoroutineUseDetector extends CoroutineBasedDetector {
public static final String CAPTURE_SUPPRESSION_KEY = "CoroutineCapture";
public static final String SCOPE_SUPPRESSION_KEY = "CoroutineMayNotBeInScope";
public IncorrectCoroutineUseDetector(JavacTask task) {
super(task);
}
@Override
protected TreeScanner<?, ?> createScanner(CompilationUnitTree compilationUnit) {
return new Scanner(compilationUnit);
}
private static final class State {
/** Tracks captured coroutine parameters from external lambda expressions. */
private final List<? extends VariableElement> m_nonlocalCoroutineParameters;
/**
* Tracks coroutine parameters from the most local lambda expression. Captures from outer lambda
* expressions will be in {@link #m_nonlocalCoroutineParameters}.
*/
private final List<? extends VariableElement> m_localCoroutineParameters;
private State(
List<? extends VariableElement> nonlocalCoroutineParameters,
List<? extends VariableElement> coroutineParameters) {
m_nonlocalCoroutineParameters = List.copyOf(nonlocalCoroutineParameters);
m_localCoroutineParameters = List.copyOf(coroutineParameters);
}
boolean isLocalCoroutine(VariableElement el) {
return m_localCoroutineParameters.contains(el);
}
boolean isCapturedCoroutine(VariableElement el) {
return m_nonlocalCoroutineParameters.contains(el) || m_localCoroutineParameters.contains(el);
}
}
private final class Scanner extends TreeScanner<State, State> {
private final CompilationUnitTree m_root;
private final Trees m_trees;
Scanner(CompilationUnitTree compilationUnit) {
m_root = compilationUnit;
m_trees = Trees.instance(m_task);
}
@Override
public State visitLambdaExpression(LambdaExpressionTree node, State state) {
// Scan parameters to see if any are coroutines
List<? extends VariableElement> coroutineParameters =
node.getParameters().stream()
.filter(
parameter -> {
var paramPath = m_trees.getPath(m_root, parameter.getType());
var paramTypeMirror = m_trees.getTypeMirror(paramPath);
return m_task.getTypes().isSameType(paramTypeMirror, m_coroutineType);
})
.map(
variableTree -> {
return (VariableElement)
m_trees.getElement(m_trees.getPath(m_root, variableTree));
})
.toList();
if (coroutineParameters.isEmpty()) {
// This lambda function does not take coroutine arguments
return super.visitLambdaExpression(node, state);
}
State localState;
if (state == null) {
localState = new State(List.of(), coroutineParameters);
} else {
localState =
new State(
concat(state.m_nonlocalCoroutineParameters, state.m_localCoroutineParameters),
coroutineParameters);
}
return super.visitLambdaExpression(node, localState);
}
@Override
public State visitMethodInvocation(MethodInvocationTree node, State state) {
if (state == null || state.m_localCoroutineParameters.isEmpty()) {
// No coroutines in scope, do nothing
return super.visitMethodInvocation(node, state);
}
var path = m_trees.getPath(m_root, node);
if (Suppressions.hasSuppression(m_trees, path, SCOPE_SUPPRESSION_KEY)) {
// Suppressed this warning, bail
return super.visitMethodInvocation(node, state);
}
// Check for calling a method on a nonlocal coroutine (eg `outerCoroutine.yield()`)
var sel = node.getMethodSelect();
if (sel instanceof MemberSelectTree ms && ms.getExpression() instanceof IdentifierTree id) {
var idPath = m_trees.getPath(m_root, id);
var el = m_trees.getElement(idPath);
if (el instanceof VariableElement ve
&& ve.asType().equals(m_coroutineType)
&& !state.isLocalCoroutine(ve)) {
m_trees.printMessage(
Diagnostic.Kind.ERROR, nonlocalCoroutineUsageMessage(ve, state), node, m_root);
}
}
// Check for passing a nonlocal coroutine to a method (eg `foo(outerCoroutine)`)
for (var arg : node.getArguments()) {
var argPath = m_trees.getPath(m_root, arg);
var el = m_trees.getElement(argPath);
if (el instanceof VariableElement ve
&& ve.asType().equals(m_coroutineType)
&& !state.isLocalCoroutine(ve)) {
m_trees.printMessage(
Diagnostic.Kind.ERROR, nonlocalCoroutineUsageMessage(ve, state), node, m_root);
}
}
return super.visitMethodInvocation(node, state);
}
private String nonlocalCoroutineUsageMessage(VariableElement ve, State state) {
StringBuilder optionsBuilder = new StringBuilder();
if (state.m_localCoroutineParameters.size() == 1) {
optionsBuilder
.append('`')
.append(state.m_localCoroutineParameters.get(0).getSimpleName())
.append('`');
} else if (state.m_localCoroutineParameters.size() == 2) {
optionsBuilder
.append('`')
.append(state.m_localCoroutineParameters.get(0).getSimpleName())
.append("` or `")
.append(state.m_localCoroutineParameters.get(1).getSimpleName())
.append('`');
} else {
for (int i = 0; i < state.m_localCoroutineParameters.size(); i++) {
var coroutineParameter = state.m_localCoroutineParameters.get(i);
optionsBuilder.append('`').append(coroutineParameter.getSimpleName()).append('`');
if (i < state.m_localCoroutineParameters.size() - 2) {
optionsBuilder.append(", ");
} else if (i == state.m_localCoroutineParameters.size() - 2) {
optionsBuilder.append(", or ");
}
}
}
return "Coroutine `%s` may not be in scope. Consider using %s"
.formatted(ve.getSimpleName(), optionsBuilder);
}
@Override
public State visitAssignment(AssignmentTree node, State state) {
if (state == null || state.m_localCoroutineParameters.isEmpty()) {
return super.visitAssignment(node, state);
}
// Check if the assignment is to a field, and if the expression is a local or nonlocal
// coroutine.
var variable = node.getVariable();
var variablePath = m_trees.getPath(m_root, variable);
if (Suppressions.hasSuppression(m_trees, variablePath, CAPTURE_SUPPRESSION_KEY)) {
// User suppressed this error, skip
return super.visitAssignment(node, state);
}
var variableElement = m_trees.getElement(variablePath);
if (variableElement instanceof VariableElement ve
&& ve.getEnclosingElement() instanceof TypeElement
&& ve.asType().equals(m_coroutineType)) {
// Assigning to a coroutine-typed field. Check the expression to see if we're assigning
// a captured coroutine.
var expression = node.getExpression();
var expressionPath = m_trees.getPath(m_root, expression);
var expressionElement = m_trees.getElement(expressionPath);
if (expressionElement instanceof VariableElement ve2 && state.isCapturedCoroutine(ve2)) {
m_trees.printMessage(
Diagnostic.Kind.ERROR,
"Captured coroutines may not be stored in fields",
node,
m_root);
}
}
return super.visitAssignment(node, state);
}
// Concatenates two lists and returns the result.
// Why is this not in the standard library?
private static <E> List<E> concat(List<? extends E> a, List<? extends E> b) {
var result = new ArrayList<E>(a.size() + b.size());
result.addAll(a);
result.addAll(b);
return result;
}
}
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.javacplugin;
import com.sun.source.util.TreePath;
import com.sun.source.util.Trees;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/** Utility class for checking for compiler error and warning suppressions. */
public final class Suppressions {
private Suppressions() {
throw new UnsupportedOperationException("This is a utility class!");
}
/**
* Checks if the given tree has a suppression annotation for at least one of the given keys. The
* "all" suppression key is automatically included.
*
* @param trees The tree utils to use to loop up elements.
* @param tree The tree to the node to check for suppressions.
* @param suppressionKeys The suppression keys to check for.
* @return True if the tree has a suppression annotation for at least one of the given keys or
* "all". False otherwise
*/
public static boolean hasSuppression(Trees trees, TreePath tree, String... suppressionKeys) {
TreePath currentPath = tree;
Set<String> suppressionKeysSet = new HashSet<>(List.of(suppressionKeys));
suppressionKeysSet.add("all");
while (currentPath != null) {
var element = trees.getElement(currentPath);
currentPath = currentPath.getParentPath();
if (element == null) {
continue;
}
var suppression = element.getAnnotation(SuppressWarnings.class);
if (suppression == null) {
continue;
}
for (String key : suppression.value()) {
if (suppressionKeysSet.contains(key)) {
return true;
}
}
}
// No suppression annotations found on the element or any parent element.
return false;
}
}

View File

@@ -20,6 +20,9 @@ public class WPILibJavacPlugin implements Plugin {
@Override
public void init(JavacTask task, String... args) {
task.addTaskListener(new ReturnValueUsedListener(task));
task.addTaskListener(new CoroutineYieldInLoopDetector(task));
task.addTaskListener(new CodeAfterCoroutineParkDetector(task));
task.addTaskListener(new IncorrectCoroutineUseDetector(task));
}
@Override

View File

@@ -0,0 +1,240 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.javacplugin;
import static com.google.testing.compile.CompilationSubject.assertThat;
import static com.google.testing.compile.Compiler.javac;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.wpilib.javacplugin.CompileTestUtils.getErrorSource;
import static org.wpilib.javacplugin.CompileTestUtils.kJavaVersionOptions;
import com.google.testing.compile.Compilation;
import com.google.testing.compile.JavaFileObjects;
import org.junit.jupiter.api.Test;
class CodeAfterCoroutineParkDetectorTest {
private static final String kCoroutineSource =
"""
package org.wpilib.commands3;
public interface Coroutine {
void park();
}
""";
@Test
void soloPark() {
String source =
"""
package frc.robot;
import java.util.function.Consumer;
import org.wpilib.commands3.Coroutine;
class Example {
Consumer<Coroutine> lambda = coroutine -> {
coroutine.park();
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).succeededWithoutWarnings();
}
@Test
void printAfterPark() {
String source =
"""
package frc.robot;
import java.util.function.Consumer;
import org.wpilib.commands3.Coroutine;
class Example {
Consumer<Coroutine> lambda = coroutine -> {
coroutine.park();
System.out.println("Unreachable 1"); // this line should get an error
System.out.println("Unreachable 2"); // but not this one
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).failed();
assertEquals(1, compilation.errors().size());
var error = compilation.errors().get(0);
assertEquals(
"Unreachable statement: `coroutine.park()` will never exit", error.getMessage(null));
assertEquals("System.out.println(\"Unreachable 1\"); ", getErrorSource(error));
}
@Test
void printAfterParkInBlock() {
String source =
"""
package frc.robot;
import java.util.function.Consumer;
import org.wpilib.commands3.Coroutine;
class Example {
Consumer<Coroutine> lambda = coroutine -> {
{
// Only .park() calls at the top level are detected
coroutine.park();
}
System.out.println("Unreachable 1");
System.out.println("Unreachable 2");
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).succeededWithoutWarnings();
}
@Test
void loopAfterPark() {
String source =
"""
package frc.robot;
import java.util.function.Consumer;
import org.wpilib.commands3.Coroutine;
class Example {
Consumer<Coroutine> lambda = coroutine -> {
coroutine.park();
for (;;) {
}
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).failed();
assertEquals(1, compilation.errors().size());
var error = compilation.errors().get(0);
assertEquals(
"Unreachable statement: `coroutine.park()` will never exit", error.getMessage(null));
assertEquals("for (;;) {\n }\n", getErrorSource(error));
}
@Test
void blockAfterPark() {
String source =
"""
package frc.robot;
import java.util.function.Consumer;
import org.wpilib.commands3.Coroutine;
class Example {
Consumer<Coroutine> lambda = coroutine -> {
coroutine.park();
{
}
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).failed();
assertEquals(1, compilation.errors().size());
var error = compilation.errors().get(0);
assertEquals(
"Unreachable statement: `coroutine.park()` will never exit", error.getMessage(null));
assertEquals("{\n }\n", getErrorSource(error));
}
@Test
void emptyStatementAfterPark() {
String source =
"""
package frc.robot;
import java.util.function.Consumer;
import org.wpilib.commands3.Coroutine;
class Example {
Consumer<Coroutine> lambda = coroutine -> {
coroutine.park();;
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).succeededWithoutWarnings();
}
@Test
void printAfterEmptyStatementAfterPark() {
String source =
"""
package frc.robot;
import java.util.function.Consumer;
import org.wpilib.commands3.Coroutine;
class Example {
Consumer<Coroutine> lambda = coroutine -> {
coroutine.park();;
System.out.println("Unreachable");
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).failed();
assertEquals(1, compilation.errors().size());
var error = compilation.errors().get(0);
assertEquals(
"Unreachable statement: `coroutine.park()` will never exit", error.getMessage(null));
assertEquals("System.out.println(\"Unreachable\");\n", getErrorSource(error));
}
}

View File

@@ -1,13 +0,0 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.javacplugin;
import java.util.List;
public class CompileTestOptions {
public static final int kJavaVersion = 17;
public static final List<Object> kJavaVersionOptions =
List.of("-source", kJavaVersion, "-target", kJavaVersion);
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.javacplugin;
import java.io.IOException;
import java.util.List;
import javax.tools.Diagnostic;
import javax.tools.JavaFileObject;
public final class CompileTestUtils {
public static final int kJavaVersion = 17;
public static final List<Object> kJavaVersionOptions =
List.of("-source", kJavaVersion, "-target", kJavaVersion);
private CompileTestUtils() {
// Utility class
}
/**
* Gets the source code for a given compiler error.
*
* @param diagnostic The diagnostic to get the source for.
* @return The source code for the given diagnostic.
*/
public static String getErrorSource(Diagnostic<? extends JavaFileObject> diagnostic) {
try (var reader = diagnostic.getSource().openReader(true)) {
int sourceLength = (int) (diagnostic.getEndPosition() - diagnostic.getStartPosition() + 1);
char[] buf = new char[sourceLength];
long skipCnt = reader.skip(diagnostic.getStartPosition());
if (skipCnt != diagnostic.getStartPosition()) {
// Didn't skip to the expected position; bail
return "<unknown source>";
}
int readCnt = reader.read(buf);
if (readCnt != sourceLength) {
// Didn't read the expected length of text; bail
return "<unknown source>";
}
return new String(buf);
} catch (IOException e) {
return "<unknown source>";
}
}
}

View File

@@ -0,0 +1,456 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.javacplugin;
import static com.google.testing.compile.CompilationSubject.assertThat;
import static com.google.testing.compile.Compiler.javac;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.wpilib.javacplugin.CompileTestUtils.kJavaVersionOptions;
import com.google.testing.compile.Compilation;
import com.google.testing.compile.JavaFileObjects;
import org.junit.jupiter.api.Test;
class CoroutineInLoopListenerTest {
private static final String kCoroutineSource =
"""
package org.wpilib.commands3;
public interface Coroutine {
void yield();
}
""";
@Test
void noYieldInLoopWithoutCoroutines() {
String source =
"""
package frc.robot;
class Example {
Runnable lambda = () -> {
while (true) {
}
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).succeededWithoutWarnings();
}
@Test
void basicYieldInLoopInLambda() {
String source =
"""
package frc.robot;
import java.util.function.Consumer;
import org.wpilib.commands3.Coroutine;
class Example {
Consumer<Coroutine> lambda = coroutine -> {
while (true) {
coroutine.yield();
}
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).succeededWithoutWarnings();
}
@Test
void basicYieldInLoopInMethod() {
String source =
"""
package frc.robot;
import java.util.function.Consumer;
import org.wpilib.commands3.Coroutine;
class Example {
void useCoroutine(Coroutine coroutine) {
while (true) {
coroutine.yield();
}
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).succeededWithoutWarnings();
}
@Test
void noYieldInLoopInLambda() {
String source =
"""
package frc.robot;
import java.util.function.Consumer;
import org.wpilib.commands3.Coroutine;
class Example {
Consumer<Coroutine> lambda = coroutine -> {
while (true) {
// No yield
}
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).failed();
assertEquals(1, compilation.errors().size());
var error = compilation.errors().get(0);
assertEquals("Missing call to `coroutine.yield()` inside loop", error.getMessage(null));
}
@Test
void yieldInLoopInRunnableInLambda() {
String source =
"""
package frc.robot;
import java.util.function.Consumer;
import org.wpilib.commands3.Coroutine;
class Example {
Consumer<Coroutine> lambda = coroutine -> {
Runnable r = () -> {
while (true) {
coroutine.yield();
}
};
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
// TODO: Should we make it an error to yield (or invoke any methods on) a captured coroutine?
assertThat(compilation).succeededWithoutWarnings();
}
@Test
void noYieldInMethodWithManyCoroutineParams() {
String source =
"""
package frc.robot;
import java.util.function.Consumer;
import org.wpilib.commands3.Coroutine;
class Example {
void manyCoroutineParams(
Coroutine firstCoroutine,
Coroutine c1,
Coroutine next,
Coroutine thisMightBeTheLastOne) {
while (true) {
// No yield
}
}
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).failed();
assertEquals(1, compilation.errors().size());
var error = compilation.errors().get(0);
assertEquals(
"Missing call to "
+ "`firstCoroutine.yield()`, "
+ "`c1.yield()`, "
+ "`next.yield()`, or "
+ "`thisMightBeTheLastOne.yield()` inside loop",
error.getMessage(null));
}
@Test
void noYieldsInNestedLambda() {
String source =
"""
package frc.robot;
import java.util.function.Consumer;
import org.wpilib.commands3.Coroutine;
class Example {
Consumer<Coroutine> lambda = coroutine -> {
Consumer<Coroutine> lambda2 = innerCoroutine -> {
while (true) {
// No yield
}
};
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).failed();
assertEquals(1, compilation.errors().size());
var error = compilation.errors().get(0);
assertEquals("Missing call to `innerCoroutine.yield()` inside loop", error.getMessage(null));
}
@Test
void nestedLambdaYieldsToOuter() {
String source =
"""
package frc.robot;
import java.util.function.Consumer;
import org.wpilib.commands3.Coroutine;
class Example {
Consumer<Coroutine> lambda = outerCoroutine -> {
Consumer<Coroutine> lambda2 = innerCoroutine -> {
while (true) {
outerCoroutine.yield();
}
};
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).failed();
// One error for not calling a yielding function on the most local coroutine object,
// and another error for calling a method on a captured coroutine (from a different analyzer)
assertEquals(2, compilation.errors().size());
var error = compilation.errors().get(0);
assertEquals("Missing call to `innerCoroutine.yield()` inside loop", error.getMessage(null));
}
@Test
void noYieldsInNestedLoops() {
String source =
"""
package frc.robot;
import java.util.function.Consumer;
import org.wpilib.commands3.Coroutine;
class Example {
Consumer<Coroutine> lambda = coroutine -> {
while (0 == 0) {
while (1 == 1) {
// inner - no yield
}
// outer - also no yield
}
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).failed();
assertEquals(2, compilation.errors().size());
var error1 = compilation.errors().get(0);
assertEquals("Missing call to `coroutine.yield()` inside loop", error1.getMessage(null));
assertEquals(8, error1.getLineNumber());
var error2 = compilation.errors().get(1);
assertEquals("Missing call to `coroutine.yield()` inside loop", error2.getMessage(null));
assertEquals(9, error2.getLineNumber());
}
@Test
void noYieldInOuterLoopButYieldInInnerLoop() {
String source =
"""
package frc.robot;
import java.util.function.Consumer;
import org.wpilib.commands3.Coroutine;
class Example {
Consumer<Coroutine> lambda = coroutine -> {
while (true) {
coroutine.yield();
while (true) {
// No yields
}
}
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).failed();
assertEquals(1, compilation.errors().size());
var error = compilation.errors().get(0);
assertEquals("Missing call to `coroutine.yield()` inside loop", error.getMessage(null));
assertEquals(10, error.getLineNumber());
}
@Test
void noYieldInInnerLoopButYieldInOuterLoop() {
String source =
"""
package frc.robot;
import java.util.function.Consumer;
import org.wpilib.commands3.Coroutine;
class Example {
Consumer<Coroutine> lambda = coroutine -> {
while (true) {
while (true) {
coroutine.yield();
}
// No yields
}
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).failed();
assertEquals(1, compilation.errors().size());
var error = compilation.errors().get(0);
assertEquals("Missing call to `coroutine.yield()` inside loop", error.getMessage(null));
assertEquals(8, error.getLineNumber());
}
@Test
void noYieldsInDeeplyNestedLoops() {
String source =
"""
package frc.robot;
import java.util.function.Consumer;
import org.wpilib.commands3.Coroutine;
class Example {
Consumer<Coroutine> lambda = coroutine -> {
while (0 == 0) {
while (1 == 1) {
while (2 == 2) {
while (3 == 3) {
while (4 == 4) {
while (5 == 5) {
if (true) {
// nested if statement shouldn't mess with error detection or reporting
while (6 == 6) {
}
}
}
}
}
}
}
}
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).failed();
assertEquals(7, compilation.errors().size());
var error1 = compilation.errors().get(0);
assertEquals("Missing call to `coroutine.yield()` inside loop", error1.getMessage(null));
assertEquals(8, error1.getLineNumber());
var error2 = compilation.errors().get(1);
assertEquals("Missing call to `coroutine.yield()` inside loop", error2.getMessage(null));
assertEquals(9, error2.getLineNumber());
var error3 = compilation.errors().get(2);
assertEquals("Missing call to `coroutine.yield()` inside loop", error3.getMessage(null));
assertEquals(10, error3.getLineNumber());
var error4 = compilation.errors().get(3);
assertEquals("Missing call to `coroutine.yield()` inside loop", error4.getMessage(null));
assertEquals(11, error4.getLineNumber());
var error5 = compilation.errors().get(4);
assertEquals("Missing call to `coroutine.yield()` inside loop", error5.getMessage(null));
assertEquals(12, error5.getLineNumber());
var error6 = compilation.errors().get(5);
assertEquals("Missing call to `coroutine.yield()` inside loop", error6.getMessage(null));
assertEquals(13, error6.getLineNumber());
var error7 = compilation.errors().get(6);
assertEquals("Missing call to `coroutine.yield()` inside loop", error7.getMessage(null));
assertEquals(16, error7.getLineNumber());
}
}

View File

@@ -0,0 +1,303 @@
// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.
package org.wpilib.javacplugin;
import static com.google.testing.compile.CompilationSubject.assertThat;
import static com.google.testing.compile.Compiler.javac;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.wpilib.javacplugin.CompileTestUtils.getErrorSource;
import static org.wpilib.javacplugin.CompileTestUtils.kJavaVersionOptions;
import com.google.testing.compile.Compilation;
import com.google.testing.compile.JavaFileObjects;
import org.junit.jupiter.api.Test;
class IncorrectCoroutineUseDetectorTest {
private static final String kCoroutineSource =
"""
package org.wpilib.commands3;
public interface Coroutine {
void yield();
}
""";
@Test
void methodCalledOnNonlocalCoroutine() {
String source =
"""
package frc.robot;
import org.wpilib.commands3.Coroutine;
import java.util.function.Consumer;
class Example {
Consumer<Coroutine> lambda = outerCoroutine -> {
Consumer<Coroutine> lambda2 = innerCoroutine -> {
outerCoroutine.yield();
};
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).failed();
assertEquals(1, compilation.errors().size());
var error = compilation.errors().get(0);
assertEquals(
"Coroutine `outerCoroutine` may not be in scope. Consider using `innerCoroutine`",
error.getMessage(null));
}
@Test
void nonlocalCoroutinePassedToMethod() {
String source =
"""
package frc.robot;
import org.wpilib.commands3.Coroutine;
import java.util.function.Consumer;
class Example {
Consumer<Coroutine> lambda = outerCoroutine -> {
Consumer<Coroutine> lambda2 = innerCoroutine -> {
method(outerCoroutine);
};
};
void method(Coroutine foo) { }
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).failed();
assertEquals(1, compilation.errors().size());
var error = compilation.errors().get(0);
assertEquals(
"Coroutine `outerCoroutine` may not be in scope. Consider using `innerCoroutine`",
error.getMessage(null));
}
@Test
void twoLocalCoroutines() {
String source =
"""
package frc.robot;
import org.wpilib.commands3.Coroutine;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
class Example {
Consumer<Coroutine> lambda = outerCoroutine -> {
BiConsumer<Coroutine, Coroutine> lambda2 = (a, b) -> {
method(outerCoroutine);
};
};
void method(Coroutine foo) { }
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).failed();
assertEquals(1, compilation.errors().size());
var error = compilation.errors().get(0);
assertEquals(
"Coroutine `outerCoroutine` may not be in scope. Consider using `a` or `b`",
error.getMessage(null));
}
@Test
void threeLocalCoroutines() {
String source =
"""
package frc.robot;
import org.wpilib.commands3.Coroutine;
import java.util.function.Consumer;
@FunctionalInterface
interface TriConsumer<T, U, V> {
void accept(T t, U u, V v);
}
class Example {
Consumer<Coroutine> lambda = outerCoroutine -> {
TriConsumer<Coroutine, Coroutine, Coroutine> lambda2 = (a, b, c) -> {
method(outerCoroutine);
};
};
void method(Coroutine foo) { }
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).failed();
assertEquals(1, compilation.errors().size());
var error = compilation.errors().get(0);
assertEquals(
"Coroutine `outerCoroutine` may not be in scope. Consider using `a`, `b`, or `c`",
error.getMessage(null));
}
@Test
void coroutineSavedToFieldErrors() {
String source =
"""
package frc.robot;
import org.wpilib.commands3.Coroutine;
import java.util.function.Consumer;
class Example {
Coroutine coroutineField;
Consumer<Coroutine> lambda = coroutine -> {
coroutineField = coroutine;
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).failed();
assertEquals(1, compilation.errors().size());
var error = compilation.errors().get(0);
assertEquals("Captured coroutines may not be stored in fields", error.getMessage(null));
}
@Test
void outerCoroutineSavedToFieldErrors() {
String source =
"""
package frc.robot;
import org.wpilib.commands3.Coroutine;
import java.util.function.Consumer;
class Example {
Coroutine coroutineField;
Consumer<Coroutine> lambda = outerCoroutine -> {
Consumer<Coroutine> lambda2 = innerCoroutine -> {
coroutineField = outerCoroutine;
coroutineField = innerCoroutine;
};
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).failed();
assertEquals(2, compilation.errors().size());
var error1 = compilation.errors().get(0);
assertEquals("Captured coroutines may not be stored in fields", error1.getMessage(null));
assertEquals(11, error1.getLineNumber());
assertEquals("coroutineField = outerCoroutine;", getErrorSource(error1));
var error2 = compilation.errors().get(1);
assertEquals("Captured coroutines may not be stored in fields", error2.getMessage(null));
assertEquals("coroutineField = innerCoroutine;", getErrorSource(error2));
assertEquals(12, error2.getLineNumber());
}
@Test
void coroutineSavedToVariableIsAllowed() {
String source =
"""
package frc.robot;
import org.wpilib.commands3.Coroutine;
import java.util.function.Consumer;
class Example {
Consumer<Coroutine> lambda = coroutine -> {
Coroutine local = coroutine;
};
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).succeededWithoutWarnings();
}
// This test is really to make it clear that the compiler plugin can't feasibly check for
// everything, rather than this being a usecase we actually want to support.
@Test
void coroutineSavedToFieldViaMethodCannotBeDetected() {
String source =
"""
package frc.robot;
import org.wpilib.commands3.Coroutine;
import java.util.function.Consumer;
class Example {
private Coroutine coroutineField;
Consumer<Coroutine> lambda = coroutine -> {
saveCoroutine(coroutine);
};
void saveCoroutine(Coroutine coroutine) {
coroutineField = coroutine;
}
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString("org.wpilib.commands3.Coroutine", kCoroutineSource),
JavaFileObjects.forSourceString("frc.robot.Example", source));
assertThat(compilation).succeededWithoutWarnings();
}
}

View File

@@ -7,7 +7,7 @@ package org.wpilib.javacplugin;
import static com.google.testing.compile.CompilationSubject.assertThat;
import static com.google.testing.compile.Compiler.javac;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.wpilib.javacplugin.CompileTestOptions.kJavaVersionOptions;
import static org.wpilib.javacplugin.CompileTestUtils.kJavaVersionOptions;
import com.google.testing.compile.Compilation;
import com.google.testing.compile.JavaFileObjects;