diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 88f6c0e..7483a99 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -5,6 +5,16 @@ on: permissions: contents: write jobs: + test: + name: Test + runs-on: ubuntu-latest + container: wpilib/roborio-cross-ubuntu:2025-24.04 + steps: + - uses: actions/checkout@v4 + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Compile and run tests on robot code + run: ./gradlew test linting: name: Linting runs-on: ubuntu-latest diff --git a/build.gradle b/build.gradle index d870be0..7ceddcb 100755 --- a/build.gradle +++ b/build.gradle @@ -13,6 +13,14 @@ repositories { dependencies { implementation "com.squareup:javapoet:1.13.0" + + testImplementation 'junit:junit:4.13.2' + testImplementation 'com.google.testing.compile:compile-testing:0.21.0' + testImplementation 'com.google.truth:truth:1.4.4' +} + +test { + useJUnit() } publishing { @@ -27,3 +35,42 @@ publishing { } } } + +import org.gradle.api.tasks.testing.logging.TestExceptionFormat +import org.gradle.api.tasks.testing.logging.TestLogEvent + +tasks.withType(Test) { + testLogging { + // set options for log level LIFECYCLE + events TestLogEvent.FAILED, + TestLogEvent.PASSED, + TestLogEvent.SKIPPED, + TestLogEvent.STANDARD_OUT + exceptionFormat TestExceptionFormat.FULL + showExceptions true + showCauses true + showStackTraces true + + // set options for log level DEBUG and INFO + debug { + events TestLogEvent.STARTED, + TestLogEvent.FAILED, + TestLogEvent.PASSED, + TestLogEvent.SKIPPED, + TestLogEvent.STANDARD_ERROR, + TestLogEvent.STANDARD_OUT + exceptionFormat TestExceptionFormat.FULL + } + info.events = debug.events + info.exceptionFormat = debug.exceptionFormat + + afterSuite { desc, result -> + if (!desc.parent) { // will match the outermost suite + def output = "Results: ${result.resultType} (${result.testCount} tests, ${result.successfulTestCount} passed, ${result.failedTestCount} failed, ${result.skippedTestCount} skipped)" + def startItem = '| ', endItem = ' |' + def repeatLength = startItem.length() + output.length() + endItem.length() + println('\n' + ('-' * repeatLength) + '\n' + startItem + output + endItem + '\n' + ('-' * repeatLength)) + } + } + } +} diff --git a/src/main/java/org/frc5572/robotools/AnnotationGenerator.java b/src/main/java/org/frc5572/robotools/AnnotationGenerator.java new file mode 100644 index 0000000..901f960 --- /dev/null +++ b/src/main/java/org/frc5572/robotools/AnnotationGenerator.java @@ -0,0 +1,28 @@ +package org.frc5572.robotools; + +import javax.annotation.processing.ProcessingEnvironment; +import javax.annotation.processing.RoundEnvironment; +import javax.lang.model.element.TypeElement; + +/** + * A specific annotation that generates java classes. + */ +public interface AnnotationGenerator { + + /** + * Provide the generator the processing environment. + */ + public void init(ProcessingEnvironment env); + + /** + * @return the full canonical name of the annotation type this generator handles e.g. + * "frc.robot.util.GenerateEmptyIO" + */ + public String getAnnotationQualifiedName(); + + /** + * Called from the processor for each annotation type on each round. + */ + public void generate(TypeElement annotation, RoundEnvironment roundEnv); + +} diff --git a/src/main/java/org/frc5572/robotools/AnnotationMirrorVisitor.java b/src/main/java/org/frc5572/robotools/AnnotationMirrorVisitor.java deleted file mode 100644 index 1b8ff21..0000000 --- a/src/main/java/org/frc5572/robotools/AnnotationMirrorVisitor.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.frc5572.robotools; - -import java.util.List; -import javax.lang.model.element.AnnotationMirror; -import javax.lang.model.element.AnnotationValue; -import javax.lang.model.util.SimpleAnnotationValueVisitor8; - -/** Find all uses of an annotation in annotation values */ -public class AnnotationMirrorVisitor extends SimpleAnnotationValueVisitor8 { - - @Override - public AnnotationMirror visitAnnotation(AnnotationMirror arg0, Void arg1) { - return arg0; - } - - @Override - public AnnotationMirror visitArray(List arg0, Void arg1) { - for (var item : arg0) { - var res = this.visit(item, arg1); - if (res != null) { - return res; - } - } - return null; - } - -} diff --git a/src/main/java/org/frc5572/robotools/BoolVisitor.java b/src/main/java/org/frc5572/robotools/BoolVisitor.java deleted file mode 100644 index c8a2ccc..0000000 --- a/src/main/java/org/frc5572/robotools/BoolVisitor.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.frc5572.robotools; - -import javax.lang.model.util.SimpleAnnotationValueVisitor8; - -/** Find all uses of a string in annotation values */ -public class BoolVisitor extends SimpleAnnotationValueVisitor8 { - - @Override - public Boolean visitBoolean(boolean arg0, Void arg1) { - return arg0; - } - -} diff --git a/src/main/java/org/frc5572/robotools/ClassListVisitor.java b/src/main/java/org/frc5572/robotools/ClassListVisitor.java deleted file mode 100644 index 6b8e9c9..0000000 --- a/src/main/java/org/frc5572/robotools/ClassListVisitor.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.frc5572.robotools; - -import java.util.List; -import javax.lang.model.element.AnnotationValue; -import javax.lang.model.type.TypeMirror; -import javax.lang.model.util.SimpleAnnotationValueVisitor8; - -/** Find all uses of a type in annotation values */ -public class ClassListVisitor extends SimpleAnnotationValueVisitor8 { - - private final List mirrors; - - /** Find all uses of a type in annotation values */ - public ClassListVisitor(List mirrors) { - super(); - this.mirrors = mirrors; - } - - @Override - public Void visitType(TypeMirror arg0, Void arg1) { - mirrors.add(arg0); - return null; - } - - @Override - public Void visitArray(List arg0, Void arg1) { - for (var item : arg0) { - this.visit(item); - } - return null; - } - -} diff --git a/src/main/java/org/frc5572/robotools/ClassVisitor.java b/src/main/java/org/frc5572/robotools/ClassVisitor.java deleted file mode 100644 index 306aa03..0000000 --- a/src/main/java/org/frc5572/robotools/ClassVisitor.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.frc5572.robotools; - -import javax.lang.model.type.TypeMirror; -import javax.lang.model.util.SimpleAnnotationValueVisitor8; - -/** Find all uses of a type in annotation values */ -public class ClassVisitor extends SimpleAnnotationValueVisitor8 { - - @Override - public TypeMirror visitType(TypeMirror arg0, Void arg1) { - return arg0; - } - -} diff --git a/src/main/java/org/frc5572/robotools/RobotProcessor.java b/src/main/java/org/frc5572/robotools/RobotProcessor.java index b7ee52f..424e6d5 100644 --- a/src/main/java/org/frc5572/robotools/RobotProcessor.java +++ b/src/main/java/org/frc5572/robotools/RobotProcessor.java @@ -1,232 +1,59 @@ package org.frc5572.robotools; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; +import java.util.Arrays; import java.util.Set; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import javax.annotation.processing.AbstractProcessor; import javax.annotation.processing.ProcessingEnvironment; import javax.annotation.processing.RoundEnvironment; -import javax.annotation.processing.SupportedAnnotationTypes; import javax.annotation.processing.SupportedSourceVersion; import javax.lang.model.SourceVersion; -import javax.lang.model.element.Element; -import javax.lang.model.element.ElementKind; -import javax.lang.model.element.ExecutableElement; -import javax.lang.model.element.Modifier; -import javax.lang.model.element.PackageElement; import javax.lang.model.element.TypeElement; -import javax.lang.model.element.VariableElement; -import javax.lang.model.type.TypeMirror; -import javax.tools.Diagnostic; -import javax.tools.Diagnostic.Kind; -import com.squareup.javapoet.JavaFile; -import com.squareup.javapoet.MethodSpec; -import com.squareup.javapoet.ParameterSpec; -import com.squareup.javapoet.TypeName; -import com.squareup.javapoet.TypeSpec; +import org.frc5572.robotools.emptyio.GenerateEmptyIOGenerator; +import org.frc5572.robotools.typestate.TypeStateBuilderGenerator; /** * Annotation processor for checks. Used by VS Code. */ -@SupportedAnnotationTypes({"frc.robot.util.typestate.TypeStateBuilder", - "frc.robot.util.GenerateEmptyIO"}) @SupportedSourceVersion(SourceVersion.RELEASE_11) public class RobotProcessor extends AbstractProcessor { + private AnnotationGenerator[] generators = new AnnotationGenerator[] { + // @formatter:off + new GenerateEmptyIOGenerator(), + new TypeStateBuilderGenerator(), + // @formatter:on + }; + /** Initialization function */ @Override public synchronized void init(ProcessingEnvironment processingEnv) { super.init(processingEnv); - } - - /** Process all elements. */ - @Override - public boolean process(Set annotations, RoundEnvironment roundEnv) { - boolean success = false; - for (var anno : annotations) { - // System.out.println("Trying " + anno.getQualifiedName()); - if (anno.getSimpleName().toString().equals("GenerateEmptyIO")) { - processGenerateEmptyIO(anno, roundEnv); - success = true; - } else if (anno.getSimpleName().toString().equals("TypeStateBuilder")) { - processGenerateTypeStateBuilder(anno, roundEnv); - success = true; - } + for (var gen : generators) { + gen.init(processingEnv); } - return success; } - private void processGenerateTypeStateBuilder(TypeElement annotation, - RoundEnvironment roundEnv) { - roundEnv.getElementsAnnotatedWith(annotation).forEach(constructorElement_ -> { - ExecutableElement constructorElement = (ExecutableElement) constructorElement_; - Element parent_ = constructorElement.getEnclosingElement(); - if (!(parent_ instanceof TypeElement)) { - processingEnv.getMessager().printMessage(Kind.ERROR, - "TypeStateBuilder constructor must be the direct child of a TypeElement (e.g. class). Instead found " - + parent_.getKind().toString() + ".", - constructorElement); - } - TypeElement parent = (TypeElement) parent_; - String builderName = parent.getSimpleName() + "Builder"; - String builderPackage = getPackageName(parent); - boolean isLinear = false; - // System.out.println("Processing " + builderPackage + "." + builderName); - for (var mirror : constructorElement.getAnnotationMirrors()) { - if (!mirror.getAnnotationType().asElement().getSimpleName().toString() - .equals("TypeStateBuilder")) { - continue; - } - for (var ev : mirror.getElementValues().entrySet()) { - if (ev.getKey().getSimpleName().toString().equals("value")) { - String res = ev.getValue().accept(new StringVisitor(), null); - if (res != null) { - builderName = res; - } - } else if (ev.getKey().getSimpleName().toString().equals("linear")) { - Boolean res = ev.getValue().accept(new BoolVisitor(), null); - if (res != null) { - isLinear = res; - } - } - } - } - - List fields = new ArrayList<>(); - List params = constructorElement.getParameters(); - for (int i = 0; i < params.size(); i++) { - boolean found = false; - VariableElement param = params.get(i); - for (var mirror : param.getAnnotationMirrors()) { - if (mirror.getAnnotationType().asElement().getSimpleName().toString() - .equals("InitField")) { - if (found) { - processingEnv.getMessager().printMessage(Kind.ERROR, - "Each parameter of a TypeStateBuilder constructor can only have one of @InitField, @RequiredField or @OptionalField", - param); - } - fields.add(new TypeStateBuilder.InitField(param.asType(), - param.getSimpleName().toString())); - found = true; - } else if (mirror.getAnnotationType().asElement().getSimpleName().toString() - .equals("RequiredField")) { - if (found) { - processingEnv.getMessager().printMessage(Kind.ERROR, - "Each parameter of a TypeStateBuilder constructor can only have one of @InitField, @RequiredField or @OptionalField", - param); - } - fields.add(TypeStateBuilder.RequiredField.fromAnnotation(param.asType(), - param.getSimpleName().toString(), mirror)); - found = true; - } else if (mirror.getAnnotationType().asElement().getSimpleName().toString() - .equals("OptionalField")) { - if (found) { - processingEnv.getMessager().printMessage(Kind.ERROR, - "Each parameter of a TypeStateBuilder constructor can only have one of @InitField, @RequiredField or @OptionalField", - param); - } - fields.add(TypeStateBuilder.OptionalField.fromAnnotation(param.asType(), - param.getSimpleName().toString(), mirror)); - found = true; - } - } - if (!found) { - processingEnv.getMessager().printMessage(Kind.ERROR, - "Each parameter of a TypeStateBuilder constructor must have one of @InitField, @RequiredField or @OptionalField", - param); - } - } - - var specBuilder = - TypeSpec.classBuilder(builderName).addModifiers(Modifier.PUBLIC, Modifier.FINAL); - - TypeStateBuilder typeStateBuilder = new TypeStateBuilder(builderName, isLinear, - fields.toArray(TypeStateBuilder.Field[]::new), parent.asType()); - typeStateBuilder.apply(specBuilder); - - var spec = specBuilder.build(); - - JavaFile file = JavaFile.builder(builderPackage, spec).build(); - try { - file.writeTo(processingEnv.getFiler()); - } catch (IOException e) { - processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, - "Failed to write class", constructorElement); - e.printStackTrace(); - } - }); + @Override + public Set getSupportedAnnotationTypes() { + return Arrays.stream(generators).map(x -> x.getAnnotationQualifiedName()) + .collect(Collectors.toSet()); } - private void processGenerateEmptyIO(TypeElement annotation, RoundEnvironment roundEnv) { - roundEnv.getElementsAnnotatedWith(annotation).forEach(classElement_ -> { - TypeElement classElement = (TypeElement) classElement_; - String emptyClassName = classElement.getSimpleName() + "Empty"; - String emptyPackage = getPackageName(classElement); - - List params = new ArrayList<>(); - - for (var mirror : classElement.getAnnotationMirrors()) { - if (!mirror.getAnnotationType().asElement().getSimpleName().toString() - .equals("GenerateEmptyIO")) { - continue; - } - for (var ev : mirror.getElementValues().entrySet()) { - if (ev.getKey().getSimpleName().toString().equals("value")) { - ev.getValue().accept(new ClassListVisitor(params), null); - } - } - } - - var specBuilder = TypeSpec.classBuilder(emptyClassName) - .addSuperinterface(TypeName.get(classElement.asType())) - .addModifiers(Modifier.PUBLIC, Modifier.FINAL); - - AtomicInteger i = new AtomicInteger(); - var constructor = MethodSpec.constructorBuilder().addModifiers(Modifier.PUBLIC) - .addParameters(params.stream().map(ty -> { - return ParameterSpec.builder(TypeName.get(ty), "arg" + i.incrementAndGet()) - .build(); - }).toList()).build(); - - for (var element : classElement.getEnclosedElements()) { - if (element instanceof ExecutableElement javaMethod) { - specBuilder - .addMethod(MethodSpec.methodBuilder(javaMethod.getSimpleName().toString()) - .addModifiers(Modifier.PUBLIC).addAnnotation(Override.class) - .returns(TypeName.VOID) - .addParameters(javaMethod.getParameters().stream().map(param -> { - return ParameterSpec.builder(TypeName.get(param.asType()), - param.getSimpleName().toString()).build(); - }).toList()).addCode("// Intentionally do nothing").build()); + /** Process all elements. */ + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + boolean handledAny = false; + for (TypeElement annotation : annotations) { + String name = annotation.getQualifiedName().toString(); + for (var gen : generators) { + if (gen.getAnnotationQualifiedName().equals(name)) { + gen.generate(annotation, roundEnv); + handledAny = true; } } - - specBuilder = specBuilder.addMethod(constructor); - - var spec = specBuilder.build(); - - JavaFile file = JavaFile.builder(emptyPackage, spec).build(); - try { - file.writeTo(processingEnv.getFiler()); - } catch (IOException e) { - processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, - "Failed to write class", classElement); - e.printStackTrace(); - } - }); - } - - private static String getPackageName(Element e) { - while (e != null) { - if (e.getKind().equals(ElementKind.PACKAGE)) { - return ((PackageElement) e).getQualifiedName().toString(); - } - e = e.getEnclosingElement(); } - - return null; + return handledAny; } } diff --git a/src/main/java/org/frc5572/robotools/StringVisitor.java b/src/main/java/org/frc5572/robotools/StringVisitor.java deleted file mode 100644 index 1b87b3c..0000000 --- a/src/main/java/org/frc5572/robotools/StringVisitor.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.frc5572.robotools; - -import javax.lang.model.util.SimpleAnnotationValueVisitor8; - -/** Find all uses of a string in annotation values */ -public class StringVisitor extends SimpleAnnotationValueVisitor8 { - - @Override - public String visitString(String arg0, Void arg1) { - return arg0; - } - -} diff --git a/src/main/java/org/frc5572/robotools/Utilities.java b/src/main/java/org/frc5572/robotools/Utilities.java new file mode 100644 index 0000000..3181b58 --- /dev/null +++ b/src/main/java/org/frc5572/robotools/Utilities.java @@ -0,0 +1,209 @@ +package org.frc5572.robotools; + +import java.util.ArrayList; +import java.util.List; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.PackageElement; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.SimpleAnnotationValueVisitor8; + +/** + * Utility methods for annotation processors or other compile-time tools. + * + *

+ * All methods are static; this class is not intended to be instantiated. + */ +public class Utilities { + + private Utilities() {} + + /** + * Returns the fully qualified package name that contains the given element. + * + *

+ * The method walks up the enclosing-element chain until a {@link ElementKind#PACKAGE} is found. + * If no package element is found (which is unlikely in normal use), {@code null} is returned. + * + * @param e the element whose package name should be determined; may be {@code null} + * @return the fully qualified package name of the element, or {@code null} if no package + * element can be found + */ + public static String getPackageName(Element e) { + while (e != null) { + if (e.getKind().equals(ElementKind.PACKAGE)) { + return ((PackageElement) e).getQualifiedName().toString(); + } + e = e.getEnclosingElement(); + } + + return null; + } + + /** + * Extracts a {@link String} value from an {@link AnnotationValue}. + * + *

+ * This is typically used for annotation members declared as {@code String}. + * + * @param value the annotation value to read + * @return the contained string value, or {@code null} if the value is not a string + */ + private static class StringVisitor extends SimpleAnnotationValueVisitor8 { + + @Override + public String visitString(String arg0, Void arg1) { + return arg0; + } + + } + + public static String stringAnnotationValue(AnnotationValue value) { + return value.accept(new StringVisitor(), null); + } + + private static class ClassVisitor extends SimpleAnnotationValueVisitor8 { + + @Override + public TypeMirror visitType(TypeMirror arg0, Void arg1) { + return arg0; + } + + } + + /** + * Extracts a single {@link TypeMirror} (class) value from an {@link AnnotationValue}. + * + *

+ * This is typically used for annotation members declared as a single {@code Class} type. + * + * @param value the annotation value to read + * @return the contained {@link TypeMirror}, or {@code null} if the value is not a single type + */ + public static TypeMirror classAnnotationValue(AnnotationValue value) { + return value.accept(new ClassVisitor(), null); + } + + private static class ClassListVisitor extends SimpleAnnotationValueVisitor8 { + + private final List mirrors; + + /** Find all uses of a type in annotation values */ + public ClassListVisitor(List mirrors) { + super(); + this.mirrors = mirrors; + } + + @Override + public Void visitType(TypeMirror arg0, Void arg1) { + mirrors.add(arg0); + return null; + } + + @Override + public Void visitArray(List arg0, Void arg1) { + for (var item : arg0) { + this.visit(item); + } + return null; + } + + } + + /** + * Extracts all {@link TypeMirror} (class) values from the given {@link AnnotationValue} into an + * existing list. + * + *

+ * This method supports both single class values and arrays of classes from annotation members + * declared as {@code Class} or {@code Class[]}. + * + * @param value the annotation value to read + * @param out the list into which all discovered {@link TypeMirror}s are added; must not be + * {@code null} + */ + public static void classListAnnotationValue(AnnotationValue value, List out) { + value.accept(new ClassListVisitor(out), null); + } + + /** + * Extracts all {@link TypeMirror} (class) values from the given {@link AnnotationValue}. + * + *

+ * This is a convenience overload of {@link #classListAnnotationValue(AnnotationValue, List)} + * that creates and returns a new list. + * + * @param value the annotation value to read + * @return a list of all {@link TypeMirror}s contained in the annotation value; never + * {@code null}, but may be empty + */ + public static List classListAnnotationValue(AnnotationValue value) { + List out = new ArrayList<>(); + classListAnnotationValue(value, out); + return out; + } + + /** + * Extracts a {@code boolean} value from an {@link AnnotationValue}. + * + *

+ * This is typically used for annotation members declared as {@code boolean}. + * + * @param value the annotation value to read + * @return the contained boolean value, or {@code null} if the value is not a boolean + */ + private static class BoolVisitor extends SimpleAnnotationValueVisitor8 { + + @Override + public Boolean visitBoolean(boolean arg0, Void arg1) { + return arg0; + } + + } + + public static Boolean boolAnnotationValue(AnnotationValue value) { + return value.accept(new BoolVisitor(), null); + } + + private static class AnnotationMirrorVisitor + extends SimpleAnnotationValueVisitor8 { + + @Override + public AnnotationMirror visitAnnotation(AnnotationMirror arg0, Void arg1) { + return arg0; + } + + @Override + public AnnotationMirror visitArray(List arg0, Void arg1) { + for (var item : arg0) { + var res = this.visit(item, arg1); + if (res != null) { + return res; + } + } + return null; + } + + } + + /** + * Extracts a nested {@link AnnotationMirror} from an {@link AnnotationValue}. + * + *

+ * This is typically used for annotation members declared as another annotation type, possibly + * in an array. + * + *

+ * If the value is an array, the first nested annotation found is returned. + * + * @param value the annotation value to read + * @return the contained {@link AnnotationMirror}, or {@code null} if the value does not contain + * an annotation + */ + public static AnnotationMirror annotationAnnotationValue(AnnotationValue value) { + return value.accept(new AnnotationMirrorVisitor(), null); + } + +} diff --git a/src/main/java/org/frc5572/robotools/emptyio/GenerateEmptyIOGenerator.java b/src/main/java/org/frc5572/robotools/emptyio/GenerateEmptyIOGenerator.java new file mode 100644 index 0000000..28d1061 --- /dev/null +++ b/src/main/java/org/frc5572/robotools/emptyio/GenerateEmptyIOGenerator.java @@ -0,0 +1,100 @@ +package org.frc5572.robotools.emptyio; + + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import javax.annotation.processing.ProcessingEnvironment; +import javax.annotation.processing.RoundEnvironment; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; +import javax.lang.model.type.TypeMirror; +import javax.tools.Diagnostic; +import org.frc5572.robotools.AnnotationGenerator; +import org.frc5572.robotools.Utilities; +import com.squareup.javapoet.JavaFile; +import com.squareup.javapoet.MethodSpec; +import com.squareup.javapoet.ParameterSpec; +import com.squareup.javapoet.TypeName; +import com.squareup.javapoet.TypeSpec; + +/** + * Annotation Generator for {@code @GenerateEmptyIO} + */ +public class GenerateEmptyIOGenerator implements AnnotationGenerator { + + private ProcessingEnvironment processingEnv; + + @Override + public void init(ProcessingEnvironment env) { + this.processingEnv = env; + } + + @Override + public String getAnnotationQualifiedName() { + return "frc.robot.util.GenerateEmptyIO"; + } + + @Override + public void generate(TypeElement annotation, RoundEnvironment roundEnv) { + roundEnv.getElementsAnnotatedWith(annotation).forEach(classElement_ -> { + TypeElement classElement = (TypeElement) classElement_; + String emptyClassName = classElement.getSimpleName() + "Empty"; + String emptyPackage = Utilities.getPackageName(classElement); + + List params = new ArrayList<>(); + + for (var mirror : classElement.getAnnotationMirrors()) { + if (!mirror.getAnnotationType().asElement().getSimpleName().toString() + .equals("GenerateEmptyIO")) { + continue; + } + for (var ev : mirror.getElementValues().entrySet()) { + if (ev.getKey().getSimpleName().toString().equals("value")) { + Utilities.classListAnnotationValue(ev.getValue(), params); + } + } + } + + var specBuilder = TypeSpec.classBuilder(emptyClassName) + .addSuperinterface(TypeName.get(classElement.asType())) + .addModifiers(Modifier.PUBLIC, Modifier.FINAL); + + AtomicInteger i = new AtomicInteger(); + var constructor = MethodSpec.constructorBuilder().addModifiers(Modifier.PUBLIC) + .addParameters(params.stream().map(ty -> { + return ParameterSpec.builder(TypeName.get(ty), "arg" + i.incrementAndGet()) + .build(); + }).toList()).build(); + + for (var element : classElement.getEnclosedElements()) { + if (element instanceof ExecutableElement javaMethod) { + specBuilder + .addMethod(MethodSpec.methodBuilder(javaMethod.getSimpleName().toString()) + .addModifiers(Modifier.PUBLIC).addAnnotation(Override.class) + .returns(TypeName.VOID) + .addParameters(javaMethod.getParameters().stream().map(param -> { + return ParameterSpec.builder(TypeName.get(param.asType()), + param.getSimpleName().toString()).build(); + }).toList()).addCode("// Intentionally do nothing").build()); + } + } + + specBuilder = specBuilder.addMethod(constructor); + + var spec = specBuilder.build(); + + JavaFile file = JavaFile.builder(emptyPackage, spec).build(); + try { + file.writeTo(processingEnv.getFiler()); + } catch (IOException e) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, + "Failed to write class", classElement); + e.printStackTrace(); + } + }); + } + +} diff --git a/src/main/java/org/frc5572/robotools/typestate/AltMethod.java b/src/main/java/org/frc5572/robotools/typestate/AltMethod.java new file mode 100644 index 0000000..dd26760 --- /dev/null +++ b/src/main/java/org/frc5572/robotools/typestate/AltMethod.java @@ -0,0 +1,47 @@ +package org.frc5572.robotools.typestate; + +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.type.TypeMirror; +import org.frc5572.robotools.Utilities; + +/** Alternative method for a field. */ +public record AltMethod(TypeMirror type, String parameterName, String code) { + /** Alternative method for a field. */ + public static AltMethod fromAnnotation(AnnotationMirror mirror, String defaultName) { + if (mirror == null) { + System.out.println("annotation is null"); + return null; + } + + if (!mirror.getAnnotationType().asElement().getSimpleName().toString() + .equals("AltMethod")) { + System.out.println("annotation name doesn't match " + + mirror.getAnnotationType().asElement().getSimpleName().toString()); + return null; + } + TypeMirror type = null; + String parameterName = defaultName; + String code = null; + + for (var ev : mirror.getElementValues().entrySet()) { + if (ev.getKey().getSimpleName().toString().equals("type")) { + type = Utilities.classAnnotationValue(ev.getValue()); + } else if (ev.getKey().getSimpleName().toString().equals("parameter_name")) { + parameterName = Utilities.stringAnnotationValue(ev.getValue()); + } else if (ev.getKey().getSimpleName().toString().equals("value")) { + code = Utilities.stringAnnotationValue(ev.getValue()); + } + } + + if (type == null) { + System.out.println("Missing type"); + return null; + } + if (code == null) { + System.out.println("Missing code"); + return null; + } + + return new AltMethod(type, parameterName, code); + } +} diff --git a/src/main/java/org/frc5572/robotools/typestate/Field.java b/src/main/java/org/frc5572/robotools/typestate/Field.java new file mode 100644 index 0000000..31094fe --- /dev/null +++ b/src/main/java/org/frc5572/robotools/typestate/Field.java @@ -0,0 +1,17 @@ +package org.frc5572.robotools.typestate; + +import javax.lang.model.type.TypeMirror; + +/** Base class for fields */ +public class Field { + /** Field type */ + public final TypeMirror type; + /** Field name */ + public final String name; + + /** Base class for fields */ + public Field(TypeMirror type, String name) { + this.type = type; + this.name = name; + } +} diff --git a/src/main/java/org/frc5572/robotools/typestate/InitField.java b/src/main/java/org/frc5572/robotools/typestate/InitField.java new file mode 100644 index 0000000..d77804d --- /dev/null +++ b/src/main/java/org/frc5572/robotools/typestate/InitField.java @@ -0,0 +1,11 @@ +package org.frc5572.robotools.typestate; + +import javax.lang.model.type.TypeMirror; + +/** Field that must be provided when creating a builder */ +public class InitField extends Field { + /** Field that must be provided when creating a builder */ + public InitField(TypeMirror type, String name) { + super(type, name); + } +} diff --git a/src/main/java/org/frc5572/robotools/typestate/MethodField.java b/src/main/java/org/frc5572/robotools/typestate/MethodField.java new file mode 100644 index 0000000..8639cfa --- /dev/null +++ b/src/main/java/org/frc5572/robotools/typestate/MethodField.java @@ -0,0 +1,20 @@ +package org.frc5572.robotools.typestate; + +import javax.lang.model.type.TypeMirror; + +/** A non-init field */ +public class MethodField extends Field { + /** An alternative method for fulfilling this field. */ + public final AltMethod alt; + + /** A non-init field */ + public MethodField(TypeMirror type, String name, AltMethod alt) { + super(type, name); + this.alt = alt; + } + + /** A non-init field */ + public MethodField(TypeMirror type, String name) { + this(type, name, null); + } +} diff --git a/src/main/java/org/frc5572/robotools/typestate/OptionalField.java b/src/main/java/org/frc5572/robotools/typestate/OptionalField.java new file mode 100644 index 0000000..03ad3fa --- /dev/null +++ b/src/main/java/org/frc5572/robotools/typestate/OptionalField.java @@ -0,0 +1,39 @@ +package org.frc5572.robotools.typestate; + +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.type.TypeMirror; +import org.frc5572.robotools.Utilities; + +/** A field that has a default in case it is not specified. */ +public class OptionalField extends MethodField { + /** Java expression that provides the default value. */ + public final String default_code; + + /** A field that has a default in case it is not specified. */ + public OptionalField(TypeMirror type, String name, AltMethod alt, String default_code) { + super(type, name, alt); + this.default_code = default_code; + } + + /** A field that has a default in case it is not specified. */ + public OptionalField(TypeMirror type, String name, String default_code) { + super(type, name); + this.default_code = default_code; + } + + /** A field that has a default in case it is not specified. */ + public static OptionalField fromAnnotation(TypeMirror type, String name, + AnnotationMirror mirror) { + String defaultCode = ""; + AltMethod alt = null; + for (var ev : mirror.getElementValues().entrySet()) { + if (ev.getKey().getSimpleName().toString().equals("value")) { + defaultCode = Utilities.stringAnnotationValue(ev.getValue()); + } else if (ev.getKey().getSimpleName().toString().equals("alt")) { + AnnotationMirror altMirror = Utilities.annotationAnnotationValue(ev.getValue()); + alt = AltMethod.fromAnnotation(altMirror, name); + } + } + return new OptionalField(type, name, alt, defaultCode); + } +} diff --git a/src/main/java/org/frc5572/robotools/typestate/RequiredField.java b/src/main/java/org/frc5572/robotools/typestate/RequiredField.java new file mode 100644 index 0000000..9d7a877 --- /dev/null +++ b/src/main/java/org/frc5572/robotools/typestate/RequiredField.java @@ -0,0 +1,32 @@ +package org.frc5572.robotools.typestate; + +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.type.TypeMirror; +import org.frc5572.robotools.Utilities; + +/** A field that is required to finish the builder. */ +public class RequiredField extends MethodField { + + /** A field that is required to finish the builder. */ + public RequiredField(TypeMirror type, String name, AltMethod alt) { + super(type, name, alt); + } + + /** A field that is required to finish the builder. */ + public RequiredField(TypeMirror type, String name) { + super(type, name); + } + + /** A field that is required to finish the builder. */ + public static RequiredField fromAnnotation(TypeMirror type, String name, + AnnotationMirror mirror) { + AltMethod alt = null; + for (var ev : mirror.getElementValues().entrySet()) { + if (ev.getKey().getSimpleName().toString().equals("alt")) { + AnnotationMirror altMirror = Utilities.annotationAnnotationValue(ev.getValue()); + alt = AltMethod.fromAnnotation(altMirror, name); + } + } + return new RequiredField(type, name, alt); + } +} diff --git a/src/main/java/org/frc5572/robotools/TypeStateBuilder.java b/src/main/java/org/frc5572/robotools/typestate/TypeStateBuilder.java similarity index 68% rename from src/main/java/org/frc5572/robotools/TypeStateBuilder.java rename to src/main/java/org/frc5572/robotools/typestate/TypeStateBuilder.java index 9a68f50..5109705 100644 --- a/src/main/java/org/frc5572/robotools/TypeStateBuilder.java +++ b/src/main/java/org/frc5572/robotools/typestate/TypeStateBuilder.java @@ -1,7 +1,6 @@ -package org.frc5572.robotools; +package org.frc5572.robotools.typestate; import java.util.ArrayList; -import javax.lang.model.element.AnnotationMirror; import javax.lang.model.element.Modifier; import javax.lang.model.type.TypeMirror; import com.squareup.javapoet.ClassName; @@ -11,7 +10,7 @@ import com.squareup.javapoet.TypeName; import com.squareup.javapoet.TypeSpec; -/** Template builder for TypeState Builders */ +/** Main builder for typestate builders */ public class TypeStateBuilder { private final String name; @@ -207,7 +206,8 @@ private void addMethods(TypeSpec.Builder builder, boolean[] enabled) { method = MethodSpec.methodBuilder(field.name).returns(ClassName.get("", nextName)); method.addParameter(ParameterSpec - .builder(TypeName.get(field.alt.type), field.alt.parameterName).build()); + .builder(TypeName.get(field.alt.type()), field.alt.parameterName()) + .build()); code = "return new " + nextName + "("; isFirst = true; for (int j = 0; j < enabled.length; j++) { @@ -215,7 +215,7 @@ private void addMethods(TypeSpec.Builder builder, boolean[] enabled) { if (!isFirst) { code += ", "; } - code += field.alt.code; + code += field.alt.code(); isFirst = false; continue; } @@ -286,7 +286,7 @@ private void addMethods(TypeSpec.Builder builder, boolean[] enabled) { if (field.alt != null) { method = MethodSpec.methodBuilder(field.name).returns(ClassName.get("", thisName)); method.addParameter(ParameterSpec - .builder(TypeName.get(field.alt.type), field.alt.parameterName).build()); + .builder(TypeName.get(field.alt.type()), field.alt.parameterName()).build()); code = "return new " + thisName + "("; isFirst = true; for (int i = 0; i < enabled.length; i++) { @@ -310,7 +310,7 @@ private void addMethods(TypeSpec.Builder builder, boolean[] enabled) { code += ", "; } if (field_ == field) { - code += field.alt.code; + code += field.alt.code(); } else { code += field_.name + "_"; } @@ -322,148 +322,4 @@ private void addMethods(TypeSpec.Builder builder, boolean[] enabled) { } } - /** Base class for fields */ - public static class Field { - /** Field type */ - public final TypeMirror type; - /** Field name */ - public final String name; - - /** Base class for fields */ - public Field(TypeMirror type, String name) { - this.type = type; - this.name = name; - } - } - - /** Field that must be provided when creating a builder */ - public static class InitField extends Field { - /** Field that must be provided when creating a builder */ - public InitField(TypeMirror type, String name) { - super(type, name); - } - } - - /** A non-init field */ - public static class MethodField extends Field { - /** An alternative method for fulfilling this field. */ - public final AltMethod alt; - - /** A non-init field */ - public MethodField(TypeMirror type, String name, AltMethod alt) { - super(type, name); - this.alt = alt; - } - - /** A non-init field */ - public MethodField(TypeMirror type, String name) { - this(type, name, null); - } - } - - /** A field that is required to finish the builder. */ - public static class RequiredField extends MethodField { - - /** A field that is required to finish the builder. */ - public RequiredField(TypeMirror type, String name, AltMethod alt) { - super(type, name, alt); - } - - /** A field that is required to finish the builder. */ - public RequiredField(TypeMirror type, String name) { - super(type, name); - } - - /** A field that is required to finish the builder. */ - public static RequiredField fromAnnotation(TypeMirror type, String name, - AnnotationMirror mirror) { - AltMethod alt = null; - for (var ev : mirror.getElementValues().entrySet()) { - if (ev.getKey().getSimpleName().toString().equals("alt")) { - AnnotationMirror altMirror = - ev.getValue().accept(new AnnotationMirrorVisitor(), null); - alt = AltMethod.fromAnnotation(altMirror, name); - } - } - return new RequiredField(type, name, alt); - } - } - - /** A field that has a default in case it is not specified. */ - public static class OptionalField extends MethodField { - /** Java expression that provides the default value. */ - public final String default_code; - - /** A field that has a default in case it is not specified. */ - public OptionalField(TypeMirror type, String name, AltMethod alt, String default_code) { - super(type, name, alt); - this.default_code = default_code; - } - - /** A field that has a default in case it is not specified. */ - public OptionalField(TypeMirror type, String name, String default_code) { - super(type, name); - this.default_code = default_code; - } - - /** A field that has a default in case it is not specified. */ - public static OptionalField fromAnnotation(TypeMirror type, String name, - AnnotationMirror mirror) { - String defaultCode = ""; - AltMethod alt = null; - for (var ev : mirror.getElementValues().entrySet()) { - if (ev.getKey().getSimpleName().toString().equals("value")) { - defaultCode = ev.getValue().accept(new StringVisitor(), null); - } else if (ev.getKey().getSimpleName().toString().equals("alt")) { - AnnotationMirror altMirror = - ev.getValue().accept(new AnnotationMirrorVisitor(), null); - alt = AltMethod.fromAnnotation(altMirror, name); - } - } - return new OptionalField(type, name, alt, defaultCode); - } - } - - /** Alternative method for a field. */ - public static record AltMethod(TypeMirror type, String parameterName, String code) { - /** Alternative method for a field. */ - public static AltMethod fromAnnotation(AnnotationMirror mirror, String defaultName) { - if (mirror == null) { - System.out.println("annotation is null"); - return null; - } - - if (!mirror.getAnnotationType().asElement().getSimpleName().toString() - .equals("AltMethod")) { - System.out.println("annotation name doesn't match " - + mirror.getAnnotationType().asElement().getSimpleName().toString()); - return null; - } - TypeMirror type = null; - String parameterName = defaultName; - String code = null; - - for (var ev : mirror.getElementValues().entrySet()) { - if (ev.getKey().getSimpleName().toString().equals("type")) { - type = ev.getValue().accept(new ClassVisitor(), null); - } else if (ev.getKey().getSimpleName().toString().equals("parameter_name")) { - parameterName = ev.getValue().accept(new StringVisitor(), null); - } else if (ev.getKey().getSimpleName().toString().equals("value")) { - code = ev.getValue().accept(new StringVisitor(), null); - } - } - - if (type == null) { - System.out.println("Missing type"); - return null; - } - if (code == null) { - System.out.println("Missing code"); - return null; - } - - return new AltMethod(type, parameterName, code); - } - } - } diff --git a/src/main/java/org/frc5572/robotools/typestate/TypeStateBuilderGenerator.java b/src/main/java/org/frc5572/robotools/typestate/TypeStateBuilderGenerator.java new file mode 100644 index 0000000..37bdf37 --- /dev/null +++ b/src/main/java/org/frc5572/robotools/typestate/TypeStateBuilderGenerator.java @@ -0,0 +1,142 @@ +package org.frc5572.robotools.typestate; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.processing.ProcessingEnvironment; +import javax.annotation.processing.RoundEnvironment; +import javax.lang.model.element.Element; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.tools.Diagnostic; +import javax.tools.Diagnostic.Kind; +import org.frc5572.robotools.AnnotationGenerator; +import org.frc5572.robotools.Utilities; +import com.squareup.javapoet.JavaFile; +import com.squareup.javapoet.TypeSpec; + +/** + * Annotation Generator for {@code @TypeStateBuilder} + */ +public final class TypeStateBuilderGenerator implements AnnotationGenerator { + + private ProcessingEnvironment processingEnv; + + @Override + public void init(ProcessingEnvironment env) { + this.processingEnv = env; + } + + @Override + public String getAnnotationQualifiedName() { + return "frc.robot.util.typestate.TypeStateBuilder"; + } + + @Override + public void generate(TypeElement annotation, RoundEnvironment roundEnv) { + roundEnv.getElementsAnnotatedWith(annotation).forEach(constructorElement_ -> { + ExecutableElement constructorElement = (ExecutableElement) constructorElement_; + Element parent_ = constructorElement.getEnclosingElement(); + if (!(parent_ instanceof TypeElement)) { + processingEnv.getMessager().printMessage(Kind.ERROR, + "TypeStateBuilder constructor must be the direct child of a TypeElement (e.g. class). Instead found " + + parent_.getKind().toString() + ".", + constructorElement); + } + TypeElement parent = (TypeElement) parent_; + String builderName = parent.getSimpleName() + "Builder"; + String builderPackage = Utilities.getPackageName(parent); + boolean isLinear = false; + // System.out.println("Processing " + builderPackage + "." + builderName); + for (var mirror : constructorElement.getAnnotationMirrors()) { + if (!mirror.getAnnotationType().asElement().getSimpleName().toString() + .equals("TypeStateBuilder")) { + continue; + } + for (var ev : mirror.getElementValues().entrySet()) { + if (ev.getKey().getSimpleName().toString().equals("value")) { + String res = Utilities.stringAnnotationValue(ev.getValue()); + if (res != null) { + builderName = res; + } + } else if (ev.getKey().getSimpleName().toString().equals("linear")) { + Boolean res = Utilities.boolAnnotationValue(ev.getValue()); + if (res != null) { + isLinear = res; + } + } + } + } + + List fields = new ArrayList<>(); + List params = constructorElement.getParameters(); + for (int i = 0; i < params.size(); i++) { + boolean found = false; + VariableElement param = params.get(i); + for (var mirror : param.getAnnotationMirrors()) { + if (mirror.getAnnotationType().asElement().getSimpleName().toString() + .equals("InitField")) { + if (found) { + processingEnv.getMessager().printMessage(Kind.ERROR, + "Each parameter of a TypeStateBuilder constructor can only " + + "have one of @InitField, @RequiredField or @OptionalField", + param); + } + fields.add(new InitField(param.asType(), param.getSimpleName().toString())); + found = true; + } else if (mirror.getAnnotationType().asElement().getSimpleName().toString() + .equals("RequiredField")) { + if (found) { + processingEnv.getMessager().printMessage(Kind.ERROR, + "Each parameter of a TypeStateBuilder constructor can only " + + "have one of @InitField, @RequiredField or @OptionalField", + param); + } + fields.add(RequiredField.fromAnnotation(param.asType(), + param.getSimpleName().toString(), mirror)); + found = true; + } else if (mirror.getAnnotationType().asElement().getSimpleName().toString() + .equals("OptionalField")) { + if (found) { + processingEnv.getMessager().printMessage(Kind.ERROR, + "Each parameter of a TypeStateBuilder constructor can only " + + "have one of @InitField, @RequiredField or @OptionalField", + param); + } + fields.add(OptionalField.fromAnnotation(param.asType(), + param.getSimpleName().toString(), mirror)); + found = true; + } + } + if (!found) { + processingEnv.getMessager() + .printMessage(Kind.ERROR, + "Each parameter of a TypeStateBuilder constructor must " + + "have one of @InitField, @RequiredField or @OptionalField", + param); + } + } + + var specBuilder = + TypeSpec.classBuilder(builderName).addModifiers(Modifier.PUBLIC, Modifier.FINAL); + + TypeStateBuilder typeStateBuilder = new TypeStateBuilder(builderName, isLinear, + fields.toArray(Field[]::new), parent.asType()); + typeStateBuilder.apply(specBuilder); + + var spec = specBuilder.build(); + + JavaFile file = JavaFile.builder(builderPackage, spec).build(); + try { + file.writeTo(processingEnv.getFiler()); + } catch (IOException e) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, + "Failed to write class", constructorElement); + e.printStackTrace(); + } + }); + } +} + diff --git a/src/test/java/org/frc5572/robotools/GenerateEmptyIOTest.java b/src/test/java/org/frc5572/robotools/GenerateEmptyIOTest.java new file mode 100644 index 0000000..a8697c8 --- /dev/null +++ b/src/test/java/org/frc5572/robotools/GenerateEmptyIOTest.java @@ -0,0 +1,92 @@ +package org.frc5572.robotools; + +import static com.google.testing.compile.CompilationSubject.assertThat; +import static com.google.testing.compile.Compiler.javac; +import javax.tools.JavaFileObject; +import org.junit.Test; +import com.google.testing.compile.Compilation; +import com.google.testing.compile.JavaFileObjects; + +/** Test for {@code @GenerateEmptyIO} */ +public class GenerateEmptyIOTest { + + // @formatter:off + private static final JavaFileObject ANNOTATION = JavaFileObjects.forSourceLines( + "frc.robot.util.GenerateEmptyIO", + "package frc.robot.util;", + "import java.lang.annotation.*;", + "@Target(ElementType.TYPE)", + "@Retention(RetentionPolicy.SOURCE)", + "public @interface GenerateEmptyIO {", + " Class[] value() default {};", + "}" + ); + // @formatter:on + + /** No-arg case */ + @Test + public void generatesEmptyClassForSimpleInterface() { + // @formatter:off + JavaFileObject input = JavaFileObjects.forSourceLines( + "frc.robot.util.ExampleIO", + "package frc.robot.util;", + "@GenerateEmptyIO", + "public interface ExampleIO {", + " void setMotorVoltage(double voltage);", + " void stop();", + "}" + ); + // @formatter:on + + Compilation compilation = + javac().withProcessors(new RobotProcessor()).compile(ANNOTATION, input); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("frc/robot/util/ExampleIOEmpty"); + } + + /** Single constructor arg */ + @Test + public void generatesConstructorWithSingleArg() { + // @formatter:off + JavaFileObject input = JavaFileObjects.forSourceLines( + "frc.robot.util.OdometryIO", + "package frc.robot.util;", + "@GenerateEmptyIO(Runnable.class)", + "public interface OdometryIO {", + " void updateInputs(Object inputs);", + "}" + ); + // @formatter:on + + Compilation compilation = + javac().withProcessors(new RobotProcessor()).compile(ANNOTATION, input); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("frc/robot/util/OdometryIOEmpty") + .contentsAsUtf8String().contains("OdometryIOEmpty(Runnable arg1)"); + } + + /** Multi-arg constructor */ + @Test + public void generatesConstructorWithMultipleArgs() { + // @formatter:off + JavaFileObject input = JavaFileObjects.forSourceLines( + "frc.robot.util.ModuleIO", + "package frc.robot.util;", + "@GenerateEmptyIO({int.class, Runnable.class})", + "public interface ModuleIO {", + " void updateInputs(Object inputs);", + " void runDriveOpenLoop(double output);", + "}" + ); + // @formatter:on + + Compilation compilation = + javac().withProcessors(new RobotProcessor()).compile(ANNOTATION, input); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("frc/robot/util/ModuleIOEmpty") + .contentsAsUtf8String().contains("ModuleIOEmpty(int arg1, Runnable arg2)"); + } +} diff --git a/src/test/java/org/frc5572/robotools/TypeStateBuilderTest.java b/src/test/java/org/frc5572/robotools/TypeStateBuilderTest.java new file mode 100644 index 0000000..5b1072d --- /dev/null +++ b/src/test/java/org/frc5572/robotools/TypeStateBuilderTest.java @@ -0,0 +1,207 @@ +package org.frc5572.robotools; + +import static com.google.testing.compile.CompilationSubject.assertThat; +import static com.google.testing.compile.Compiler.javac; +import javax.tools.JavaFileObject; +import org.junit.Test; +import com.google.testing.compile.Compilation; +import com.google.testing.compile.JavaFileObjects; + +/** Tests for {@code @TypeStateBuilder} */ +public class TypeStateBuilderTest { + + // @formatter:off + private static final JavaFileObject ANN_TYPESTATE_BUILDER = JavaFileObjects.forSourceLines( + "frc.robot.util.typestate.TypeStateBuilder", + "package frc.robot.util.typestate;", + "import java.lang.annotation.*;", + "@Retention(RetentionPolicy.CLASS)", + "@Target(ElementType.CONSTRUCTOR)", + "public @interface TypeStateBuilder {", + " String value() default \"\";", + " boolean linear() default false;", + "}" + ); + private static final JavaFileObject ANN_INIT_FIELD = JavaFileObjects.forSourceLines( + "frc.robot.util.typestate.InitField", + "package frc.robot.util.typestate;", + "import java.lang.annotation.*;", + "@Retention(RetentionPolicy.CLASS)", + "@Target(ElementType.PARAMETER)", + "public @interface InitField {}" + ); + private static final JavaFileObject ANN_REQUIRED_FIELD = JavaFileObjects.forSourceLines( + "frc.robot.util.typestate.RequiredField", + "package frc.robot.util.typestate;", + "import java.lang.annotation.*;", + "@Retention(RetentionPolicy.CLASS)", + "@Target(ElementType.PARAMETER)", + "public @interface RequiredField {", + " AltMethod[] alt() default {};", + "}" + ); + private static final JavaFileObject ANN_OPTIONAL_FIELD = JavaFileObjects.forSourceLines( + "frc.robot.util.typestate.OptionalField", + "package frc.robot.util.typestate;", + "import java.lang.annotation.*;", + "@Retention(RetentionPolicy.CLASS)", + "@Target(ElementType.PARAMETER)", + "public @interface OptionalField {", + " String value();", + " AltMethod[] alt() default {};", + "}" + ); + private static final JavaFileObject ANN_ALT_METHOD = JavaFileObjects.forSourceLines( + "frc.robot.util.typestate.AltMethod", + "package frc.robot.util.typestate;", + "public @interface AltMethod {", + " Class type();", + " String parameter_name() default \"\";", + " String value();", + "}" + ); + // @formatter:on + + private Compilation compile(JavaFileObject input) { + return javac().withProcessors(new RobotProcessor()).compile(ANN_TYPESTATE_BUILDER, + ANN_INIT_FIELD, ANN_REQUIRED_FIELD, ANN_OPTIONAL_FIELD, ANN_ALT_METHOD, input); + } + + /** No value() -> builder name is {ClassName}Builder. */ + @Test + public void generatesBuilderWithDefaultName() { + // @formatter:off + JavaFileObject input = JavaFileObjects.forSourceLines( + "frc.robot.util.tunable.Color", + "package frc.robot.util.tunable;", + "import frc.robot.util.typestate.*;", + "public class Color {", + " public final int r;", + " @TypeStateBuilder", + " public Color(@RequiredField int r) { this.r = r; }", + "}" + ); + // @formatter:on + + Compilation compilation = compile(input); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("frc/robot/util/tunable/ColorBuilder"); + } + + /** value() overrides the builder name. */ + @Test + public void generatesBuilderWithCustomName() { + // @formatter:off + JavaFileObject input = JavaFileObjects.forSourceLines( + "frc.robot.util.tunable.Config", + "package frc.robot.util.tunable;", + "import frc.robot.util.typestate.*;", + "public class Config {", + " public final double value;", + " @TypeStateBuilder(\"SpecialBuilder\")", + " public Config(@RequiredField double value) { this.value = value; }", + "}" + ); + // @formatter:on + + Compilation compilation = compile(input); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("frc/robot/util/tunable/SpecialBuilder"); + } + + /** + * Each @RequiredField becomes a setter; finish() appears only when all are set. + */ + @Test + public void requiredFieldsGenerateSettersThenFinish() { + // @formatter:off + JavaFileObject input = JavaFileObjects.forSourceLines( + "frc.robot.util.tunable.Point", + "package frc.robot.util.tunable;", + "import frc.robot.util.typestate.*;", + "public class Point {", + " public final int x, y;", + " @TypeStateBuilder", + " public Point(@InitField String label, @RequiredField int x, @RequiredField int y) {", + " this.x = x; this.y = y;", + " }", + "}" + ); + // @formatter:on + + Compilation compilation = compile(input); + + assertThat(compilation).succeeded(); + // Initial builder exposes setters for all required fields + assertThat(compilation).generatedSourceFile("frc/robot/util/tunable/PointBuilder") + .contentsAsUtf8String().containsMatch("PointBuilder10 x\\("); + assertThat(compilation).generatedSourceFile("frc/robot/util/tunable/PointBuilder") + .contentsAsUtf8String().containsMatch("PointBuilder01 y\\("); + // finish() only available once both are set + assertThat(compilation).generatedSourceFile("frc/robot/util/tunable/PointBuilder") + .contentsAsUtf8String().containsMatch("Point finish\\("); + } + + /** + * @OptionalField gets a setter at every stage and defaults on the no-arg constructor. + */ + @Test + public void optionalFieldHasSetterAndDefault() { + // @formatter:off + JavaFileObject input = JavaFileObjects.forSourceLines( + "frc.robot.util.tunable.Gains", + "package frc.robot.util.tunable;", + "import frc.robot.util.typestate.*;", + "public class Gains {", + " public final double kP, kI;", + " @TypeStateBuilder", + " public Gains(@InitField String name, @RequiredField double kP, @OptionalField(\"0.0\") double kI) {", + " this.kP = kP; this.kI = kI;", + " }", + "}" + ); + // @formatter:on + + Compilation compilation = compile(input); + + assertThat(compilation).succeeded(); + // finish() available on the state where kP is set + assertThat(compilation).generatedSourceFile("frc/robot/util/tunable/GainsBuilder") + .contentsAsUtf8String().containsMatch("Gains finish\\("); + // kI setter present at the initial state + assertThat(compilation).generatedSourceFile("frc/robot/util/tunable/GainsBuilder") + .contentsAsUtf8String().containsMatch("GainsBuilder kI\\("); + } + + /** + * linear=true exposes only the next required field setter at each step. + */ + @Test + public void linearBuilderExposesOnlyNextStep() { + // @formatter:off + JavaFileObject input = JavaFileObjects.forSourceLines( + "frc.robot.util.tunable.Step", + "package frc.robot.util.tunable;", + "import frc.robot.util.typestate.*;", + "public class Step {", + " public final int a, b;", + " @TypeStateBuilder(linear = true)", + " public Step(@RequiredField int a, @RequiredField int b) {", + " this.a = a; this.b = b;", + " }", + "}" + ); + // @formatter:on + + Compilation compilation = compile(input); + + assertThat(compilation).succeeded(); + // Initial builder only exposes 'a', not 'b' yet + assertThat(compilation).generatedSourceFile("frc/robot/util/tunable/StepBuilder") + .contentsAsUtf8String().containsMatch("StepBuilder10 a\\("); + assertThat(compilation).generatedSourceFile("frc/robot/util/tunable/StepBuilder") + .contentsAsUtf8String().doesNotContainMatch("StepBuilder0. b\\("); + } +}