All Downloads are FREE. Search and download functionalities are using the official Maven repository.

xyz.block.ftl.javalang.deployment.JavaCodeGenerator Maven / Gradle / Ivy

There is a newer version: 0.415.1
Show newest version
package xyz.block.ftl.javalang.deployment;

import java.io.IOException;
import java.nio.file.Path;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Collectors;

import javax.lang.model.element.Modifier;

import org.jetbrains.annotations.NotNull;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.squareup.javapoet.AnnotationSpec;
import com.squareup.javapoet.ArrayTypeName;
import com.squareup.javapoet.ClassName;
import com.squareup.javapoet.JavaFile;
import com.squareup.javapoet.MethodSpec;
import com.squareup.javapoet.ParameterizedTypeName;
import com.squareup.javapoet.TypeName;
import com.squareup.javapoet.TypeSpec;
import com.squareup.javapoet.TypeVariableName;
import com.squareup.javapoet.WildcardTypeName;

import xyz.block.ftl.ConsumableTopic;
import xyz.block.ftl.EnumHolder;
import xyz.block.ftl.GeneratedRef;
import xyz.block.ftl.TypeAlias;
import xyz.block.ftl.TypeAliasMapper;
import xyz.block.ftl.VerbClient;
import xyz.block.ftl.deployment.JVMCodeGenerator;
import xyz.block.ftl.deployment.VerbType;
import xyz.block.ftl.schema.v1.Data;
import xyz.block.ftl.schema.v1.Enum;
import xyz.block.ftl.schema.v1.EnumVariant;
import xyz.block.ftl.schema.v1.Module;
import xyz.block.ftl.schema.v1.Topic;
import xyz.block.ftl.schema.v1.Type;
import xyz.block.ftl.schema.v1.Value;
import xyz.block.ftl.schema.v1.Verb;

public class JavaCodeGenerator extends JVMCodeGenerator {

    public static final String CLIENT = "Client";
    public static final String PACKAGE_PREFIX = "ftl.";

    @Override
    protected void generateTypeAliasMapper(String module, xyz.block.ftl.schema.v1.TypeAlias typeAlias,
            String packageName, Optional nativeTypeAlias, Path outputDir) throws IOException {
        TypeSpec.Builder typeBuilder = TypeSpec.interfaceBuilder(className(typeAlias.getName()) + TYPE_MAPPER)
                .addAnnotation(AnnotationSpec.builder(TypeAlias.class)
                        .addMember("name", "\"" + typeAlias.getName() + "\"")
                        .addMember("module", "\"" + module + "\"")
                        .build())
                .addModifiers(Modifier.PUBLIC)
                .addJavadoc(String.join("\n", typeAlias.getCommentsList()));
        if (nativeTypeAlias.isEmpty()) {
            TypeVariableName finalType = TypeVariableName.get("T");
            typeBuilder.addTypeVariable(finalType);
            typeBuilder.addSuperinterface(ParameterizedTypeName.get(ClassName.get(TypeAliasMapper.class),
                    finalType, ClassName.get(String.class)));
        } else {
            typeBuilder.addSuperinterface(ParameterizedTypeName.get(ClassName.get(TypeAliasMapper.class),
                    ClassName.bestGuess(nativeTypeAlias.get()), ClassName.get(String.class)));
        }
        TypeSpec theType = typeBuilder.build();
        JavaFile javaFile = JavaFile.builder(packageName, theType)
                .build();
        javaFile.writeTo(outputDir);
    }

    protected void generateTopicConsumer(Module module, Topic data, String packageName, Map typeAliasMap,
            Map nativeTypeAliasMap, Path outputDir) throws IOException {
        String thisType = className(data.getName() + "Topic");

        TypeSpec.Builder dataBuilder = TypeSpec.interfaceBuilder(thisType)
                .addModifiers(Modifier.PUBLIC);
        dataBuilder.addSuperinterface(ConsumableTopic.class);
        if (data.getEvent().hasRef()) {
            dataBuilder.addJavadoc("Topic {@link $L}",
                    data.getEvent().getRef().getName());
        }
        dataBuilder.addAnnotation(AnnotationSpec.builder(xyz.block.ftl.Topic.class)
                .addMember("value", "\"" + data.getName() + "\"")
                .addMember("module", "\"" + module.getName() + "\"")
                .build());

        JavaFile javaFile = JavaFile.builder(packageName, dataBuilder.build())
                .build();

        javaFile.writeTo(outputDir);
    }

    protected void generateEnum(Module module, Enum ennum, String packageName, Map typeAliasMap,
            Map nativeTypeAliasMap, Map> enumVariantInfoMap, Path outputDir)
            throws IOException {
        String interfaceType = className(ennum.getName());
        if (ennum.hasType()) {
            //Enums with a type are "value enums" - Java natively supports these
            TypeSpec.Builder dataBuilder = TypeSpec.enumBuilder(interfaceType)
                    .addAnnotation(getGeneratedRefAnnotation(module.getName(), ennum.getName()))
                    .addAnnotation(AnnotationSpec.builder(xyz.block.ftl.Enum.class).build())
                    .addModifiers(Modifier.PUBLIC)
                    .addJavadoc(String.join("\n", ennum.getCommentsList()));

            TypeName enumType = toAnnotatedJavaTypeName(ennum.getType(), typeAliasMap, nativeTypeAliasMap);
            dataBuilder.addField(enumType, "value", Modifier.PRIVATE, Modifier.FINAL);
            dataBuilder.addMethod(MethodSpec.constructorBuilder()
                    .addParameter(enumType, "value")
                    .addStatement("this.value = value")
                    .build());
            dataBuilder.addMethod(makeGetMethod("Value", enumType, "return value"));

            var format = ennum.getType().hasString() ? "$S" : "$L";
            for (var i : ennum.getVariantsList()) {
                Object value = toJavaValue(i.getValue());
                dataBuilder.addEnumConstant(i.getName(), TypeSpec.anonymousClassBuilder(format, value).build());
            }
            JavaFile javaFile = JavaFile.builder(packageName, dataBuilder.build()).build();
            javaFile.writeTo(outputDir);
        } else {
            // Enums without a type are (confusingly) "type enums". Java can't represent these directly, so we use a
            // sealed class

            // TODO JavaPoet doesn't support 'sealed' or 'permits' syntax yet, so we can't seal the interface
            // https://github.com/square/javapoet/issues/823
            TypeSpec.Builder interfaceBuilder = TypeSpec.interfaceBuilder(interfaceType)
                    .addAnnotation(getGeneratedRefAnnotation(module.getName(), ennum.getName()))
                    .addAnnotation(AnnotationSpec.builder(xyz.block.ftl.Enum.class).build())
                    .addModifiers(Modifier.PUBLIC)
                    .addJavadoc(String.join("\n", ennum.getCommentsList()));

            Map variantValuesTypes = ennum.getVariantsList().stream().collect(
                    Collectors.toMap(EnumVariant::getName, v -> toAnnotatedJavaTypeName(v.getValue().getTypeValue().getValue(),
                            typeAliasMap, nativeTypeAliasMap)));
            for (var variant : ennum.getVariantsList()) {
                // Interface has isX and getX methods for each variant
                String name = variant.getName();
                TypeName valueType = variantValuesTypes.get(name);
                interfaceBuilder.addMethod(MethodSpec.methodBuilder("is" + name)
                        .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
                        .addAnnotation(JsonIgnore.class)
                        .returns(TypeName.BOOLEAN)
                        .build());
                interfaceBuilder.addMethod(MethodSpec.methodBuilder("get" + name)
                        .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
                        .addAnnotation(JsonIgnore.class)
                        .returns(valueType)
                        .build());

                if (variant.getValue().getTypeValue().getValue().hasRef()) {
                    // Value type is a Ref, so it will have a class generated by generateDataObject
                    // Store this variant in enumVariantInfoMap so we can fetch it later
                    DeclRef key = new DeclRef(module.getName(), name);
                    List variantInfos = enumVariantInfoMap.computeIfAbsent(key, k -> new ArrayList<>());
                    variantInfos.add(new EnumInfo(interfaceType, variant, ennum.getVariantsList()));
                } else {
                    // Value type isn't a Ref, so we make a wrapper class that implements our interface
                    TypeSpec.Builder dataBuilder = TypeSpec.classBuilder(className(name))
                            .addAnnotation(getGeneratedRefAnnotation(module.getName(), name))
                            .addAnnotation(AnnotationSpec.builder(EnumHolder.class).build())
                            .addModifiers(Modifier.PUBLIC, Modifier.FINAL);
                    dataBuilder.addField(valueType, "value", Modifier.PRIVATE, Modifier.FINAL);
                    dataBuilder.addMethod(MethodSpec.constructorBuilder()
                            .addStatement("this.value = null")
                            .addModifiers(Modifier.PRIVATE)
                            .build());
                    dataBuilder.addMethod(MethodSpec.constructorBuilder()
                            .addParameter(valueType, "value")
                            .addStatement("this.value = value")
                            .addModifiers(Modifier.PUBLIC)
                            .build());
                    addTypeEnumInterfaceMethods(packageName, interfaceType, dataBuilder, name, valueType,
                            variantValuesTypes, false);
                    JavaFile javaFile = JavaFile.builder(packageName, dataBuilder.build()).build();
                    javaFile.writeTo(outputDir);
                }
            }
            JavaFile javaFile = JavaFile.builder(packageName, interfaceBuilder.build()).build();
            javaFile.writeTo(outputDir);
        }
    }

    protected void generateDataObject(Module module, Data data, String packageName, Map typeAliasMap,
            Map nativeTypeAliasMap, Map> enumVariantInfoMap, Path outputDir)
            throws IOException {
        String thisType = className(data.getName());
        TypeSpec.Builder dataBuilder = TypeSpec.classBuilder(thisType)
                .addAnnotation(getGeneratedRefAnnotation(module.getName(), data.getName()))
                .addModifiers(Modifier.PUBLIC)
                .addJavadoc(String.join("\n", data.getCommentsList()));

        // if data is part of a type enum, generate the interface methods for each variant
        DeclRef key = new DeclRef(module.getName(), data.getName());
        if (enumVariantInfoMap.containsKey(key)) {
            for (var enumVariantInfo : enumVariantInfoMap.get(key)) {
                String name = enumVariantInfo.variant().getName();
                TypeName variantTypeName = ClassName.get(packageName, name);
                Map variantValuesTypes = enumVariantInfo.otherVariants().stream().collect(
                        Collectors.toMap(EnumVariant::getName,
                                v -> toAnnotatedJavaTypeName(v.getValue().getTypeValue().getValue(),
                                        typeAliasMap, nativeTypeAliasMap)));
                addTypeEnumInterfaceMethods(packageName, enumVariantInfo.interfaceType(), dataBuilder, name,
                        variantTypeName, variantValuesTypes, true);
            }
        }

        MethodSpec.Builder allConstructor = MethodSpec.constructorBuilder().addModifiers(Modifier.PUBLIC);
        dataBuilder.addMethod(allConstructor.build());
        for (var param : data.getTypeParametersList()) {
            dataBuilder.addTypeVariable(TypeVariableName.get(param.getName()));
        }
        Map sortedFields = new TreeMap<>();

        for (var i : data.getFieldsList()) {
            TypeName dataType = toAnnotatedJavaTypeName(i.getType(), typeAliasMap, nativeTypeAliasMap);
            String name = i.getName();
            var fieldName = toJavaName(name);
            dataBuilder.addField(dataType, fieldName, Modifier.PRIVATE);
            sortedFields.put(fieldName, () -> {
                allConstructor.addParameter(dataType, fieldName);
                allConstructor.addCode("this.$L = $L;\n", fieldName, fieldName);
            });
            String methodName = Character.toUpperCase(name.charAt(0)) + name.substring(1);
            dataBuilder.addMethod(MethodSpec.methodBuilder("set" + methodName)
                    .addModifiers(Modifier.PUBLIC)
                    .addParameter(dataType, fieldName)
                    .returns(ClassName.get(packageName, thisType))
                    .addCode("this.$L = $L;\n", fieldName, fieldName)
                    .addCode("return this;")
                    .build());
            if (i.getType().hasBool()) {
                dataBuilder.addMethod(MethodSpec.methodBuilder("is" + methodName)
                        .addModifiers(Modifier.PUBLIC)
                        .returns(dataType)
                        .addCode("return $L;", fieldName)
                        .build());
            } else {
                dataBuilder.addMethod(MethodSpec.methodBuilder("get" + methodName)
                        .addModifiers(Modifier.PUBLIC)
                        .returns(dataType)
                        .addCode("return $L;", fieldName)
                        .build());
            }
        }
        if (!sortedFields.isEmpty()) {
            for (var v : sortedFields.values()) {
                v.run();
            }
            dataBuilder.addMethod(allConstructor.build());

        }
        JavaFile javaFile = JavaFile.builder(packageName, dataBuilder.build())
                .build();

        javaFile.writeTo(outputDir);
    }

    protected void generateVerb(Module module, Verb verb, String packageName, Map typeAliasMap,
            Map nativeTypeAliasMap, Path outputDir)
            throws IOException {
        String verbName = verb.getName();
        TypeSpec.Builder clientBuilder = TypeSpec.interfaceBuilder(className(verbName) + CLIENT)
                .addModifiers(Modifier.PUBLIC)
                .addJavadoc("A client for the $L.$L verb", module.getName(), verbName);

        var methodName = verbName;
        if (JAVA_KEYWORDS.contains(verbName)) {
            methodName = verbName + "_";
        }
        MethodSpec.Builder callMethod = MethodSpec.methodBuilder(methodName)
                .addModifiers(Modifier.ABSTRACT, Modifier.PUBLIC)
                .addAnnotation(AnnotationSpec.builder(VerbClient.class)
                        .addMember("module", "\"" + module.getName() + "\"")
                        .build())
                .addJavadoc(String.join("\n", verb.getCommentsList()));
        VerbType verbType = VerbType.of(verb);
        if (verbType == VerbType.SOURCE || verbType == VerbType.VERB) {
            callMethod.returns(toAnnotatedJavaTypeName(verb.getResponse(), typeAliasMap, nativeTypeAliasMap));
        }
        if (verbType == VerbType.SINK || verbType == VerbType.VERB) {
            callMethod.addParameter(toAnnotatedJavaTypeName(verb.getRequest(), typeAliasMap, nativeTypeAliasMap), "value");
        }
        clientBuilder.addMethod(callMethod.build());
        JavaFile javaFile = JavaFile.builder(packageName, clientBuilder.build()).build();
        javaFile.writeTo(outputDir);
    }

    private String toJavaName(String name) {
        if (JAVA_KEYWORDS.contains(name)) {
            return name + "_";
        }
        return name;
    }

    private TypeName toAnnotatedJavaTypeName(Type type, Map typeAliasMap,
            Map nativeTypeAliasMap) {
        var results = toJavaTypeName(type, typeAliasMap, nativeTypeAliasMap, false);
        if (type.hasRef() || type.hasArray() || type.hasBytes() || type.hasString() || type.hasMap() || type.hasTime()) {
            return results.annotated(AnnotationSpec.builder(NotNull.class).build());
        }
        return results;
    }

    private TypeName toJavaTypeName(Type type, Map typeAliasMap, Map nativeTypeAliasMap,
            boolean boxPrimitives) {
        if (type.hasArray()) {
            return ParameterizedTypeName.get(ClassName.get(List.class),
                    toJavaTypeName(type.getArray().getElement(), typeAliasMap, nativeTypeAliasMap, false));
        } else if (type.hasString()) {
            return ClassName.get(String.class);
        } else if (type.hasOptional()) {
            // Always box for optional, as normal primitives can't be null
            return toJavaTypeName(type.getOptional().getType(), typeAliasMap, nativeTypeAliasMap, true);
        } else if (type.hasRef()) {
            if (type.getRef().getModule().isEmpty()) {
                return TypeVariableName.get(type.getRef().getName());
            }
            DeclRef key = new DeclRef(type.getRef().getModule(), type.getRef().getName());
            if (nativeTypeAliasMap.containsKey(key)) {
                return ClassName.bestGuess(nativeTypeAliasMap.get(key));
            }
            if (typeAliasMap.containsKey(key)) {
                return toJavaTypeName(typeAliasMap.get(key), typeAliasMap, nativeTypeAliasMap, boxPrimitives);
            }
            var params = type.getRef().getTypeParametersList();
            ClassName className = ClassName.get(PACKAGE_PREFIX + type.getRef().getModule(), type.getRef().getName());
            if (params.isEmpty()) {
                return className;
            }
            List javaTypes = params.stream()
                    .map(s -> s.hasUnit() ? WildcardTypeName.subtypeOf(Object.class)
                            : toJavaTypeName(s, typeAliasMap, nativeTypeAliasMap, true))
                    .toList();
            return ParameterizedTypeName.get(className, javaTypes.toArray(new TypeName[javaTypes.size()]));
        } else if (type.hasMap()) {
            return ParameterizedTypeName.get(ClassName.get(Map.class),
                    toJavaTypeName(type.getMap().getKey(), typeAliasMap, nativeTypeAliasMap, true),
                    toJavaTypeName(type.getMap().getValue(), typeAliasMap, nativeTypeAliasMap, true));
        } else if (type.hasTime()) {
            return ClassName.get(ZonedDateTime.class);
        } else if (type.hasInt()) {
            return boxPrimitives ? ClassName.get(Long.class) : TypeName.LONG;
        } else if (type.hasUnit()) {
            return TypeName.VOID;
        } else if (type.hasBool()) {
            return boxPrimitives ? ClassName.get(Boolean.class) : TypeName.BOOLEAN;
        } else if (type.hasFloat()) {
            return boxPrimitives ? ClassName.get(Double.class) : TypeName.DOUBLE;
        } else if (type.hasBytes()) {
            return ArrayTypeName.of(TypeName.BYTE);
        } else if (type.hasAny()) {
            return TypeName.OBJECT;
        }

        throw new RuntimeException("Cannot generate Java type name: " + type);
    }

    /**
     * Get concrete value from a Value
     */
    private Object toJavaValue(Value value) {
        if (value.hasIntValue()) {
            return value.getIntValue().getValue();
        } else if (value.hasStringValue()) {
            return value.getStringValue().getValue();
        } else if (value.hasTypeValue()) {
            // Can't instantiate a TypeValue now. Cannot happen because it's only used in type enums
            throw new RuntimeException("Cannot generate TypeValue: " + value);
        }
        throw new RuntimeException("Cannot generate Java value: " + value);
    }

    /**
     * Adds the super interface and isX, getX methods to the dataBuilder for a type enum variant
     */
    private static void addTypeEnumInterfaceMethods(String packageName, String interfaceType, TypeSpec.Builder dataBuilder,
            String enumVariantName, TypeName variantTypeName, Map variantValuesTypes, boolean returnSelf) {
        dataBuilder.addSuperinterface(ClassName.get(packageName, interfaceType));
        // Positive implementation of isX, getX for its type
        dataBuilder.addMethod(makeIsMethod(enumVariantName, true));
        dataBuilder.addMethod(makeGetMethod(enumVariantName, variantTypeName, "return " + (returnSelf ? "this" : "value")));

        for (var variant : variantValuesTypes.entrySet()) {
            if (variant.getKey().equals(enumVariantName)) {
                continue;
            }
            // Negative implementation of isX, getX for other types
            dataBuilder.addMethod(makeIsMethod(variant.getKey(), false));
            dataBuilder.addMethod(
                    makeGetMethod(variant.getKey(), variant.getValue(), "throw new UnsupportedOperationException()"));
        }
    }

    private static @NotNull MethodSpec makeIsMethod(String name, boolean val) {
        return MethodSpec.methodBuilder("is" + name)
                .addModifiers(Modifier.PUBLIC)
                .addAnnotation(JsonIgnore.class)
                .returns(TypeName.BOOLEAN)
                .addStatement("return " + val)
                .build();
    }

    private static @NotNull MethodSpec makeGetMethod(String name, TypeName enumType, String returnStatement) {
        return MethodSpec.methodBuilder("get" + name)
                .addModifiers(Modifier.PUBLIC)
                .addAnnotation(JsonIgnore.class)
                .returns(enumType)
                .addStatement(returnStatement)
                .build();
    }

    private static @NotNull AnnotationSpec getGeneratedRefAnnotation(String module, String name) {
        return AnnotationSpec.builder(GeneratedRef.class)
                .addMember("name", "\"" + name + "\"")
                .addMember("module", "\"" + module + "\"").build();
    }

    protected static final Set JAVA_KEYWORDS = Set.of("abstract", "continue", "for", "new", "switch", "assert",
            "default", "goto", "package", "synchronized", "boolean", "do", "if", "private", "this", "break", "double",
            "implements", "protected", "throw", "byte", "else", "import", "public", "throws", "case", "enum", "instanceof",
            "return", "transient", "catch", "extends", "int", "short", "try", "char", "final", "interface", "static", "void",
            "class", "finally", "long", "strictfp", "volatile", "const", "float", "native", "super", "while");
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy