[javac] Add @MaxLength annotation for limiting lengths of string parameters (#8493)

Useful for eg OpModes, where names have a maximum length

Also includes validations for values in opmode annotations like
`@Autonomous(name = "...")`; name, group, and description all have
maximum allowable lengths
This commit is contained in:
Sam Carlberg
2026-03-06 17:20:20 -05:00
committed by GitHub
parent 9bd9656871
commit 28176f1062
6 changed files with 801 additions and 0 deletions

View File

@@ -0,0 +1,185 @@
// 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.AnnotationTree;
import com.sun.source.tree.AssignmentTree;
import com.sun.source.tree.CompilationUnitTree;
import com.sun.source.tree.ExpressionTree;
import com.sun.source.tree.LiteralTree;
import com.sun.source.tree.MethodInvocationTree;
import com.sun.source.tree.Tree.Kind;
import com.sun.source.tree.UnaryTree;
import com.sun.source.util.JavacTask;
import com.sun.source.util.TaskEvent;
import com.sun.source.util.TaskListener;
import com.sun.source.util.TreePath;
import com.sun.source.util.TreeScanner;
import com.sun.source.util.Trees;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.lang.model.element.Element;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.VariableElement;
import javax.tools.Diagnostic;
import org.wpilib.annotation.MaxLength;
public class MaxLengthDetector implements TaskListener {
private final JavacTask m_task;
private final Set<CompilationUnitTree> m_visitedCUs = new HashSet<>();
public MaxLengthDetector(JavacTask task) {
m_task = task;
}
@Override
public void finished(TaskEvent e) {
// We override `finished` instead of `started` because we want to run after the
// ANALYZE attribution phase has completed and assigned types to elements in the AST
// Track the visited CUs to avoid re-processing the same CU multiple times when we call
// `Trees.getElement()` on a tree path.
if (e.getKind() == TaskEvent.Kind.ANALYZE && m_visitedCUs.add(e.getCompilationUnit())) {
e.getCompilationUnit().accept(new Scanner(e.getCompilationUnit()), null);
}
}
private final class Scanner extends TreeScanner<Void, Void> {
private final CompilationUnitTree m_root;
private final Trees m_trees;
Scanner(CompilationUnitTree compilationUnit) {
m_root = compilationUnit;
m_trees = Trees.instance(m_task);
}
@Override
public Void visitMethodInvocation(MethodInvocationTree node, Void unused) {
// Find the invoked method to determine parameter annotations
TreePath selectPath = m_trees.getPath(m_root, node.getMethodSelect());
Element element = selectPath != null ? m_trees.getElement(selectPath) : null;
List<? extends ExpressionTree> args = node.getArguments();
if (!(element instanceof ExecutableElement method)) {
return super.visitMethodInvocation(node, unused);
}
List<? extends VariableElement> params = method.getParameters();
for (int i = 0; i < args.size(); i++) {
ExpressionTree argument = args.get(i);
if (!(argument instanceof LiteralTree literal)) {
continue;
}
if (!(literal.getValue() instanceof String string)) {
continue;
}
// Determine which parameter this argument maps to (accounting for varargs)
int paramIndex = i;
if (!params.isEmpty() && method.isVarArgs()) {
paramIndex = Math.min(i, params.size() - 1);
}
if (paramIndex >= params.size()) {
continue;
}
VariableElement param = params.get(paramIndex);
var maxLength = param.getAnnotation(MaxLength.class);
if (maxLength == null || string.length() <= maxLength.value()) {
continue;
}
m_trees.printMessage(
Diagnostic.Kind.ERROR,
("String literal exceeds maximum length: \"%s\""
+ " (%d characters) is longer than %d character%s")
.formatted(
string, string.length(), maxLength.value(), maxLength.value() == 1 ? "" : "s"),
literal,
m_root);
}
return super.visitMethodInvocation(node, unused);
}
// Checks for @MaxLength annotations with invalid configurations (zero or negative lengths)
@Override
public Void visitAnnotation(AnnotationTree node, Void unused) {
// Validate usages of @MaxLength annotation: value must be >= 1
TreePath annoTypePath = m_trees.getPath(m_root, node.getAnnotationType());
Element annoElement = annoTypePath != null ? m_trees.getElement(annoTypePath) : null;
if (!(annoElement instanceof TypeElement typeElem)
|| !"org.wpilib.annotation.MaxLength".contentEquals(typeElem.getQualifiedName())) {
return super.visitAnnotation(node, unused);
}
// Extract the annotation's single parameter "value"
ExpressionTree valueExpr = null;
List<? extends ExpressionTree> args = node.getArguments();
if (args != null && !args.isEmpty()) {
for (ExpressionTree arg : args) {
if (arg instanceof AssignmentTree assign) {
if ("value".equals(assign.getVariable().toString())) {
valueExpr = assign.getExpression();
break;
}
} else {
// Single unnamed argument form: @MaxLength(5)
valueExpr = arg;
break;
}
}
}
Integer constValue = evaluateIntConstant(valueExpr);
if (constValue != null && constValue < 1) {
m_trees.printMessage(
Diagnostic.Kind.ERROR,
"@MaxLength value must be >= 1 (was " + constValue + ")",
valueExpr,
m_root);
}
return super.visitAnnotation(node, unused);
}
private Integer evaluateIntConstant(ExpressionTree expr) {
return switch (expr) {
case null -> null;
// Literal like 0, 1, etc.
case LiteralTree lit when lit.getValue() instanceof Integer i -> {
yield i;
}
// Handle unary minus of an int literal, e.g., -1
case UnaryTree unary
when unary.getKind() == Kind.UNARY_MINUS
&& unary.getExpression() instanceof LiteralTree literal
&& literal.getValue() instanceof Integer i -> {
yield -i;
}
default -> {
// Handle references to compile-time constants (static final int)
TreePath path = m_trees.getPath(m_root, expr);
if (path != null
&& m_trees.getElement(path) instanceof VariableElement var
&& var.getConstantValue() instanceof Integer i) {
yield i;
}
yield null;
}
};
}
}
}

View File

@@ -0,0 +1,159 @@
// 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.AnnotationTree;
import com.sun.source.tree.AssignmentTree;
import com.sun.source.tree.CompilationUnitTree;
import com.sun.source.tree.ExpressionTree;
import com.sun.source.tree.LiteralTree;
import com.sun.source.util.JavacTask;
import com.sun.source.util.TaskEvent;
import com.sun.source.util.TaskListener;
import com.sun.source.util.TreePath;
import com.sun.source.util.TreeScanner;
import com.sun.source.util.Trees;
import java.util.HashSet;
import java.util.Set;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.VariableElement;
import javax.tools.Diagnostic;
/**
* Validates opmode annotations {@code @Autonomous}, {@code @Teleop}, {@code @TestOpMode}.
*
* <p>Requirements:
*
* <ul>
* <li>Name must be <= 32 characters
* <li>Group must be <= 12 characters
* <li>Description must be <= 64 characters
* </ul>
*/
public class OpModeAnnotationValidator implements TaskListener {
private final JavacTask m_task;
private final Set<CompilationUnitTree> m_visitedCUs = new HashSet<>();
/** The maximum number of permitted characters for an opmode name. */
private static final int NAME_MAX_LENGTH = 32;
/** The maximum number of permitted characters for an opmode group name. */
private static final int GROUP_MAX_LENGTH = 12;
/** The maximum number of permitted characters for an opmode description. */
private static final int DESCRIPTION_MAX_LENGTH = 64;
public OpModeAnnotationValidator(JavacTask task) {
m_task = task;
}
@Override
public void finished(TaskEvent e) {
// We override `finished` instead of `started` because we want to run after the
// ANALYZE attribution phase has completed and assigned types to elements in the AST
// Track the visited CUs to avoid re-processing the same CU multiple times when we call
// `Trees.getElement()` on a tree path.
if (e.getKind() == TaskEvent.Kind.ANALYZE && m_visitedCUs.add(e.getCompilationUnit())) {
e.getCompilationUnit().accept(new Scanner(e.getCompilationUnit()), null);
}
}
private final class Scanner extends TreeScanner<Void, Void> {
private final CompilationUnitTree m_root;
private final Trees m_trees;
Scanner(CompilationUnitTree compilationUnit) {
m_root = compilationUnit;
m_trees = Trees.instance(m_task);
}
@Override
public Void visitAnnotation(AnnotationTree node, Void unused) {
// Identify if this is one of the target annotations
TreePath annoTypePath = m_trees.getPath(m_root, node.getAnnotationType());
Element annoElement = annoTypePath != null ? m_trees.getElement(annoTypePath) : null;
if (!(annoElement instanceof TypeElement typeElem)) {
return super.visitAnnotation(node, unused);
}
CharSequence qname = typeElem.getQualifiedName();
boolean isAutonomous = "org.wpilib.opmode.Autonomous".contentEquals(qname);
boolean isTeleop = "org.wpilib.opmode.Teleop".contentEquals(qname);
boolean isTestOpMode = "org.wpilib.opmode.TestOpMode".contentEquals(qname);
if (!(isAutonomous || isTeleop || isTestOpMode)) {
return super.visitAnnotation(node, unused);
}
// Extract provided attributes (they are optional). Only check name, group, description
ExpressionTree nameExpr = null;
ExpressionTree groupExpr = null;
ExpressionTree descriptionExpr = null;
var args = node.getArguments();
if (args != null) {
for (ExpressionTree arg : args) {
if (arg instanceof AssignmentTree assign) {
String key = assign.getVariable().toString();
switch (key) {
case "name" -> nameExpr = assign.getExpression();
case "group" -> groupExpr = assign.getExpression();
case "description" -> descriptionExpr = assign.getExpression();
default -> {
// Not a field we're validating, ignore it
}
}
}
}
}
checkLength(typeElem.getSimpleName(), "name", NAME_MAX_LENGTH, nameExpr);
checkLength(typeElem.getSimpleName(), "group", GROUP_MAX_LENGTH, groupExpr);
checkLength(typeElem.getSimpleName(), "description", DESCRIPTION_MAX_LENGTH, descriptionExpr);
return super.visitAnnotation(node, unused);
}
private void checkLength(
CharSequence typeName, String fieldName, int max, ExpressionTree valueExpr) {
String value = evaluateStringConstant(valueExpr);
if (value == null || value.length() <= max) {
// If not provided or not a constant expression we can evaluate, do nothing
// If below the permitted maximum, leave it alone
return;
}
m_trees.printMessage(
Diagnostic.Kind.ERROR,
"@%s opmode %s must be <= %d characters (was %d)"
.formatted(typeName, fieldName, max, value.length()),
valueExpr,
m_root);
}
private String evaluateStringConstant(ExpressionTree expr) {
if (expr == null) {
return null;
}
// Direct literal
if (expr instanceof LiteralTree lit && lit.getValue() instanceof String string) {
return string;
}
// Reference to a compile-time constant variable
TreePath path = m_trees.getPath(m_root, expr);
if (path != null) {
Element el = m_trees.getElement(path);
if (el instanceof VariableElement var && var.getConstantValue() instanceof String string) {
return string;
}
}
return null;
}
}
}

View File

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

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 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 MaxLengthDetectorTest {
@Test
void stringLiteralLessThanConfiguredMaxLength() {
String source =
"""
package wpilib.robot;
import org.wpilib.annotation.MaxLength;
class Example {
Example(@MaxLength(10) String arg) {
}
Example() {
this("short");
}
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(JavaFileObjects.forSourceString("wpilib.robot.Example", source));
assertThat(compilation).succeededWithoutWarnings();
}
@Test
void stringLiteralLongerThanConfiguredMaxLength() {
String source =
"""
package wpilib.robot;
import org.wpilib.annotation.MaxLength;
class Example {
Example(@MaxLength(1) String arg) {
}
Example() {
this("abcdefghijklmnopqrstuvwxyz1234567890");
}
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(JavaFileObjects.forSourceString("wpilib.robot.Example", source));
assertThat(compilation).failed();
var errors = compilation.errors();
assertEquals(1, errors.size());
var error = errors.get(0);
assertEquals(
"String literal exceeds maximum length: \"abcdefghijklmnopqrstuvwxyz1234567890\""
+ " (36 characters) is longer than 1 character",
error.getMessage(null));
}
@Test
void stringLiteralConcatenationLongerThanConfiguredMaxLength() {
String source =
"""
package wpilib.robot;
import org.wpilib.annotation.MaxLength;
class Example {
Example(@MaxLength(1) String arg) {
}
Example() {
this("1" + "2");
}
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(JavaFileObjects.forSourceString("wpilib.robot.Example", source));
assertThat(compilation).failed();
var errors = compilation.errors();
assertEquals(1, errors.size());
var error = errors.get(0);
assertEquals(
"String literal exceeds maximum length: \"12\" (2 characters) is longer than 1 character",
error.getMessage(null));
}
@Test
void stringGenerationLongerThanConfiguredMaxLength() {
String source =
"""
package wpilib.robot;
import org.wpilib.annotation.MaxLength;
class Example {
Example(@MaxLength(1) String arg) {
}
Example() {
this("x".repeat(2));
}
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(JavaFileObjects.forSourceString("wpilib.robot.Example", source));
// Can't detect this
assertThat(compilation).succeededWithoutWarnings();
}
@Test
void zeroMaxLength() {
String source =
"""
package wpilib.robot;
import org.wpilib.annotation.MaxLength;
class Example {
Example(@MaxLength(0) String arg) {
}
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(JavaFileObjects.forSourceString("wpilib.robot.Example", source));
assertThat(compilation).failed();
var errors = compilation.errors();
assertEquals(1, errors.size());
var error = errors.get(0);
assertEquals("@MaxLength value must be >= 1 (was 0)", error.getMessage(null));
}
@Test
void negativeMaxLength() {
String source =
"""
package wpilib.robot;
import org.wpilib.annotation.MaxLength;
class Example {
Example(@MaxLength(-123) String arg) {
}
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(JavaFileObjects.forSourceString("wpilib.robot.Example", source));
assertThat(compilation).failed();
var errors = compilation.errors();
assertEquals(1, errors.size());
var error = errors.get(0);
assertEquals("@MaxLength value must be >= 1 (was -123)", error.getMessage(null));
}
@Test
void constantZeroMaxLength() {
String source =
"""
package wpilib.robot;
import org.wpilib.annotation.MaxLength;
class Example {
public static final int CONFIGURED_MAX_LENGTH = 0;
Example(@MaxLength(CONFIGURED_MAX_LENGTH) String arg) {
}
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(JavaFileObjects.forSourceString("wpilib.robot.Example", source));
assertThat(compilation).failed();
var errors = compilation.errors();
assertEquals(1, errors.size());
var error = errors.get(0);
assertEquals("@MaxLength value must be >= 1 (was 0)", error.getMessage(null));
}
@Test
void constantNegativeMaxLength() {
String source =
"""
package wpilib.robot;
import org.wpilib.annotation.MaxLength;
class Example {
public static final int CONFIGURED_MAX_LENGTH = -3;
Example(@MaxLength(CONFIGURED_MAX_LENGTH) String arg) {
}
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(JavaFileObjects.forSourceString("wpilib.robot.Example", source));
assertThat(compilation).failed();
var errors = compilation.errors();
assertEquals(1, errors.size());
var error = errors.get(0);
assertEquals("@MaxLength value must be >= 1 (was -3)", error.getMessage(null));
}
}

View File

@@ -0,0 +1,167 @@
// 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;
@SuppressWarnings("LineLength") // Inline source code can have long lines
class OpModeAnnotationValidatorTest {
private static final String AUTONOMOUS_ANNOTATION_SOURCE =
"""
package org.wpilib.opmode;
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Autonomous {
String name() default "";
String description() default "";
String group() default "";
}
""";
private static final String TELEOP_ANNOTATION_SOURCE =
"""
package org.wpilib.opmode;
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Teleop {
String name() default "";
String description() default "";
String group() default "";
}
""";
private static final String TEST_OPMODE_ANNOTATION_SOURCE =
"""
package org.wpilib.opmode;
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface TestOpMode {
String name() default "";
String description() default "";
String group() default "";
}
""";
@Test
void stringLiteralLessThanConfiguredMaxLength() {
String source =
"""
package wpilib.robot;
import org.wpilib.opmode.*;
@Autonomous(name = "Short Name", description = "Short Description", group = "Short Group")
@Teleop(name = "Short Name", description = "Short Description", group = "Short Group")
@TestOpMode(name = "Short Name", description = "Short Description", group = "Short Group")
class Example {
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString(
"org.wpilib.opmode.Autonomous", AUTONOMOUS_ANNOTATION_SOURCE),
JavaFileObjects.forSourceString(
"org.wpilib.opmode.Teleop", TELEOP_ANNOTATION_SOURCE),
JavaFileObjects.forSourceString(
"org.wpilib.opmode.TestOpMode", TEST_OPMODE_ANNOTATION_SOURCE),
JavaFileObjects.forSourceString("wpilib.robot.Example", source));
assertThat(compilation).succeededWithoutWarnings();
}
@Test
void stringLiteralsGreaterThanConfiguredMaxLength() {
String source =
"""
package wpilib.robot;
import org.wpilib.opmode.*;
@Autonomous(
name = "This is much longer than thirty six characters (I counted them all myself)",
description = "This is significantly longer than sixty four characters (it's ninety nine, if you bother to count!)",
group = "More than twelve characters long"
)
@Teleop(
name = "This is much longer than thirty six characters (I counted them all myself)",
description = "This is significantly longer than sixty four characters (it's ninety nine, if you bother to count!)",
group = "More than twelve characters long"
)
@TestOpMode(
name = "This is much longer than thirty six characters (I counted them all myself)",
description = "This is significantly longer than sixty four characters (it's ninety nine, if you bother to count!)",
group = "More than twelve characters long"
)
class Example {
}
""";
Compilation compilation =
javac()
.withOptions(kJavaVersionOptions)
.compile(
JavaFileObjects.forSourceString(
"org.wpilib.opmode.Autonomous", AUTONOMOUS_ANNOTATION_SOURCE),
JavaFileObjects.forSourceString(
"org.wpilib.opmode.Teleop", TELEOP_ANNOTATION_SOURCE),
JavaFileObjects.forSourceString(
"org.wpilib.opmode.TestOpMode", TEST_OPMODE_ANNOTATION_SOURCE),
JavaFileObjects.forSourceString("wpilib.robot.Example", source));
assertThat(compilation).failed();
var errors = compilation.errors();
assertEquals(9, errors.size());
// Autonomous
assertEquals(
"@Autonomous opmode name must be <= 32 characters (was 74)",
errors.get(0).getMessage(null));
assertEquals(
"@Autonomous opmode group must be <= 12 characters (was 32)",
errors.get(1).getMessage(null));
assertEquals(
"@Autonomous opmode description must be <= 64 characters (was 99)",
errors.get(2).getMessage(null));
// Teleop
assertEquals(
"@Teleop opmode name must be <= 32 characters (was 74)", errors.get(3).getMessage(null));
assertEquals(
"@Teleop opmode group must be <= 12 characters (was 32)", errors.get(4).getMessage(null));
assertEquals(
"@Teleop opmode description must be <= 64 characters (was 99)",
errors.get(5).getMessage(null));
// TestOpMode
assertEquals(
"@TestOpMode opmode name must be <= 32 characters (was 74)",
errors.get(6).getMessage(null));
assertEquals(
"@TestOpMode opmode group must be <= 12 characters (was 32)",
errors.get(7).getMessage(null));
assertEquals(
"@TestOpMode opmode description must be <= 64 characters (was 99)",
errors.get(8).getMessage(null));
}
}