diff --git a/javapoet/src/main/java/com/palantir/javapoet/CodeWriter.java b/javapoet/src/main/java/com/palantir/javapoet/CodeWriter.java index c2d3d79a..9c6eac72 100644 --- a/javapoet/src/main/java/com/palantir/javapoet/CodeWriter.java +++ b/javapoet/src/main/java/com/palantir/javapoet/CodeWriter.java @@ -25,6 +25,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.EnumSet; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -224,6 +225,38 @@ public void emitTypeVariables(List typeVariables) throws IOExc emit(">"); } + public void emitParameters(Iterable parameters, boolean varargs) throws IOException { + emit(CodeBlock.of("($Z")); + + boolean firstParameter = true; + for (Iterator parameterSpec = parameters.iterator(); parameterSpec.hasNext(); ) { + ParameterSpec parameter = parameterSpec.next(); + if (!firstParameter) { + emit(",").emitWrappingSpace(); + } + parameter.emit(this, !parameterSpec.hasNext() && varargs); + firstParameter = false; + } + + emit(")"); + } + + public void emitJavadocWithParameters(CodeBlock javadoc, Iterable parameters) throws IOException { + CodeBlock.Builder builder = javadoc.toBuilder(); + boolean emitTagNewline = true; + for (ParameterSpec parameterSpec : parameters) { + if (!parameterSpec.javadoc().isEmpty()) { + // Emit a new line before @param section only if the method javadoc is present. + if (emitTagNewline && !javadoc.isEmpty()) { + builder.add("\n"); + } + emitTagNewline = false; + builder.add("@param $L $L", parameterSpec.name(), parameterSpec.javadoc()); + } + } + emitJavadoc(builder.build()); + } + public void popTypeVariables(List typeVariables) { typeVariables.forEach(typeVariable -> currentTypeVariables.remove(typeVariable.name())); } diff --git a/javapoet/src/main/java/com/palantir/javapoet/MethodSpec.java b/javapoet/src/main/java/com/palantir/javapoet/MethodSpec.java index 94bb82c8..21306d57 100644 --- a/javapoet/src/main/java/com/palantir/javapoet/MethodSpec.java +++ b/javapoet/src/main/java/com/palantir/javapoet/MethodSpec.java @@ -24,7 +24,6 @@ import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Collections; -import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -43,7 +42,6 @@ /** A generated constructor or method declaration. */ public final class MethodSpec { private static final String CONSTRUCTOR = ""; - private final String name; private final CodeBlock javadoc; private final List annotations; @@ -55,6 +53,7 @@ public final class MethodSpec { private final List exceptions; private final CodeBlock code; private final CodeBlock defaultValue; + private final boolean compactConstructor; private MethodSpec(Builder builder) { CodeBlock code = builder.code.build(); @@ -78,6 +77,7 @@ private MethodSpec(Builder builder) { this.exceptions = Util.immutableList(builder.exceptions); this.defaultValue = builder.defaultValue; this.code = code; + this.compactConstructor = builder.compactConstructor; } public String name() { @@ -134,7 +134,7 @@ private boolean lastParameterIsArray(List parameters) { } void emit(CodeWriter codeWriter, String enclosingName, Set implicitModifiers) throws IOException { - codeWriter.emitJavadoc(javadocWithParameters()); + codeWriter.emitJavadocWithParameters(javadoc, parameters); codeWriter.emitAnnotations(annotations, false); codeWriter.emitModifiers(modifiers, implicitModifiers); @@ -143,24 +143,16 @@ void emit(CodeWriter codeWriter, String enclosingName, Set implicitMod codeWriter.emit(" "); } - if (isConstructor()) { - codeWriter.emit("$L($Z", enclosingName); + if (compactConstructor) { + codeWriter.emit("$L", enclosingName); + } else if (isConstructor()) { + codeWriter.emit("$L", enclosingName); + codeWriter.emitParameters(parameters, varargs); } else { - codeWriter.emit("$T $L($Z", returnType, name); - } - - boolean firstParameter = true; - for (Iterator i = parameters.iterator(); i.hasNext(); ) { - ParameterSpec parameter = i.next(); - if (!firstParameter) { - codeWriter.emit(",").emitWrappingSpace(); - } - parameter.emit(codeWriter, !i.hasNext() && varargs); - firstParameter = false; + codeWriter.emit("$T $L", returnType, name); + codeWriter.emitParameters(parameters, varargs); } - codeWriter.emit(")"); - if (defaultValue != null && !defaultValue.isEmpty()) { codeWriter.emit(" default "); codeWriter.emit(defaultValue); @@ -196,22 +188,6 @@ void emit(CodeWriter codeWriter, String enclosingName, Set implicitMod codeWriter.popTypeVariables(typeVariables); } - private CodeBlock javadocWithParameters() { - CodeBlock.Builder builder = javadoc.toBuilder(); - boolean emitTagNewline = true; - for (ParameterSpec parameterSpec : parameters) { - if (!parameterSpec.javadoc().isEmpty()) { - // Emit a new line before @param section only if the method javadoc is present. - if (emitTagNewline && !javadoc.isEmpty()) { - builder.add("\n"); - } - emitTagNewline = false; - builder.add("@param $L $L", parameterSpec.name(), parameterSpec.javadoc()); - } - } - return builder.build(); - } - @Override public boolean equals(Object o) { if (this == o) { @@ -244,11 +220,15 @@ public String toString() { } public static Builder methodBuilder(String name) { - return new Builder(name); + return new Builder(name, false); } public static Builder constructorBuilder() { - return new Builder(CONSTRUCTOR); + return new Builder(CONSTRUCTOR, false); + } + + public static Builder compactConstructorBuilder() { + return new Builder(CONSTRUCTOR, true); } /** @@ -336,7 +316,7 @@ public static Builder overriding(ExecutableElement method, DeclaredType enclosin } public Builder toBuilder() { - Builder builder = new Builder(name); + Builder builder = new Builder(name, compactConstructor); builder.javadoc.add(javadoc); builder.annotations.addAll(annotations); builder.modifiers.addAll(modifiers); @@ -365,8 +345,11 @@ public static final class Builder { private final List modifiers = new ArrayList<>(); private final List parameters = new ArrayList<>(); - private Builder(String name) { + private final boolean compactConstructor; + + private Builder(String name, boolean compactConstructor) { setName(name); + this.compactConstructor = compactConstructor; } public Builder setName(String name) { diff --git a/javapoet/src/main/java/com/palantir/javapoet/TypeSpec.java b/javapoet/src/main/java/com/palantir/javapoet/TypeSpec.java index cd1bd84e..227403b9 100644 --- a/javapoet/src/main/java/com/palantir/javapoet/TypeSpec.java +++ b/javapoet/src/main/java/com/palantir/javapoet/TypeSpec.java @@ -33,7 +33,6 @@ import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Set; import javax.lang.model.SourceVersion; @@ -66,6 +65,7 @@ public final class TypeSpec { private final Set nestedTypesSimpleNames; private final List originatingElements; private final Set alwaysQualifiedNames; + private final MethodSpec recordConstructor; private TypeSpec(Builder builder) { this.kind = builder.kind; @@ -85,6 +85,7 @@ private TypeSpec(Builder builder) { this.methodSpecs = Util.immutableList(builder.methodSpecs); this.typeSpecs = Util.immutableList(builder.typeSpecs); this.alwaysQualifiedNames = Util.immutableSet(builder.alwaysQualifiedNames); + this.recordConstructor = builder.recordConstructor; nestedTypesSimpleNames = new HashSet<>(); List originatingElementsMutable = new ArrayList<>(); @@ -121,6 +122,7 @@ private TypeSpec(TypeSpec type) { this.originatingElements = Collections.emptyList(); this.nestedTypesSimpleNames = Collections.emptySet(); this.alwaysQualifiedNames = Collections.emptySet(); + this.recordConstructor = null; } public Kind kind() { @@ -203,6 +205,14 @@ public static Builder classBuilder(ClassName className) { return classBuilder(checkNotNull(className, "className == null").simpleName()); } + public static Builder recordBuilder(String name) { + return new Builder(Kind.RECORD, checkNotNull(name, "name == null"), null); + } + + public static Builder recordBuilder(ClassName className) { + return recordBuilder(checkNotNull(className, "className == null").simpleName()); + } + public static Builder interfaceBuilder(String name) { return new Builder(Kind.INTERFACE, checkNotNull(name, "name == null"), null); } @@ -284,16 +294,24 @@ void emit(CodeWriter codeWriter, String enumName, Set implicitModifier // Push an empty type (specifically without nested types) for type-resolution. codeWriter.pushType(new TypeSpec(this)); - codeWriter.emitJavadoc(javadoc); - codeWriter.emitAnnotations(annotations, false); - codeWriter.emitModifiers(modifiers, Util.union(implicitModifiers, kind.asMemberModifiers)); - if (kind == Kind.ANNOTATION) { - codeWriter.emit("$L $L", "@interface", name); + if (recordConstructor != null) { + codeWriter.emitJavadocWithParameters(javadoc, recordConstructor.parameters()); } else { - codeWriter.emit("$L $L", kind.toString(), name); + codeWriter.emitJavadoc(javadoc); } + codeWriter.emitAnnotations(annotations, false); + codeWriter.emitModifiers(modifiers, Util.union(implicitModifiers, kind.asMemberModifiers)); + codeWriter.emit("$L $L", kind.keyword, name); codeWriter.emitTypeVariables(typeVariables); + if (kind == Kind.RECORD) { + if (recordConstructor != null) { + codeWriter.emitParameters(recordConstructor.parameters(), recordConstructor.varargs()); + } else { + codeWriter.emitParameters(List.of(), false); + } + } + List extendsTypes; List implementsTypes; if (kind == Kind.INTERFACE) { @@ -413,6 +431,15 @@ void emit(CodeWriter codeWriter, String enumName, Set implicitModifier firstMember = false; } + // Compact constructor. + if (recordConstructor != null && !recordConstructor.code().isEmpty()) { + if (!firstMember) { + codeWriter.emit("\n"); + } + recordConstructor.emit(codeWriter, name, kind.implicitMethodModifiers); + firstMember = false; + } + // Constructors. for (MethodSpec methodSpec : methodSpecs) { if (!methodSpec.isConstructor()) { @@ -492,52 +519,62 @@ public String toString() { @SuppressWarnings("ImmutableEnumChecker") public enum Kind { - CLASS(Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptySet()), + CLASS(Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), "class"), + + RECORD( + Collections.emptySet(), + Collections.emptySet(), + Collections.emptySet(), + Collections.emptySet(), + "record"), INTERFACE( Util.immutableSet(Arrays.asList(Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL)), Util.immutableSet(Arrays.asList(Modifier.PUBLIC, Modifier.ABSTRACT)), Util.immutableSet(Arrays.asList(Modifier.PUBLIC, Modifier.STATIC)), - Util.immutableSet(Collections.singletonList(Modifier.STATIC))), + Util.immutableSet(Collections.singletonList(Modifier.STATIC)), + "interface"), ENUM( Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), - Collections.singleton(Modifier.STATIC)), + Collections.singleton(Modifier.STATIC), + "enum"), ANNOTATION( Util.immutableSet(Arrays.asList(Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL)), Util.immutableSet(Arrays.asList(Modifier.PUBLIC, Modifier.ABSTRACT)), Util.immutableSet(Arrays.asList(Modifier.PUBLIC, Modifier.STATIC)), - Util.immutableSet(Collections.singletonList(Modifier.STATIC))); + Util.immutableSet(Collections.singletonList(Modifier.STATIC)), + "@interface"); private final Set implicitFieldModifiers; private final Set implicitMethodModifiers; private final Set implicitTypeModifiers; private final Set asMemberModifiers; + private final String keyword; Kind( Set implicitFieldModifiers, Set implicitMethodModifiers, Set implicitTypeModifiers, - Set asMemberModifiers) { + Set asMemberModifiers, + String keyword) { this.implicitFieldModifiers = implicitFieldModifiers; this.implicitMethodModifiers = implicitMethodModifiers; this.implicitTypeModifiers = implicitTypeModifiers; this.asMemberModifiers = asMemberModifiers; + this.keyword = keyword; } - @Override - public String toString() { - return name().toLowerCase(Locale.ROOT); - } } public static final class Builder { private final Kind kind; private final String name; private final CodeBlock anonymousTypeArguments; + private MethodSpec recordConstructor; private final CodeBlock.Builder javadoc = CodeBlock.builder(); private TypeName superclass = ClassName.OBJECT; @@ -691,6 +728,14 @@ public Builder addSuperinterface(TypeMirror superinterface, boolean avoidNestedT return this; } + public Builder recordConstructor(MethodSpec recordConstructor) { + if (kind != Kind.RECORD) { + throw new UnsupportedOperationException(kind + " can't have record constructor"); + } + this.recordConstructor = recordConstructor; + return this; + } + public Builder addPermittedSubclasses(Iterable permittedSubclasses) { checkArgument(permittedSubclasses != null, "permittedSubclasses == null"); for (TypeName permittedSubclass : permittedSubclasses) { @@ -913,6 +958,12 @@ public TypeSpec build() { } } + if (recordConstructor != null) { + for (ParameterSpec recordComponent : recordConstructor.parameters()) { + checkArgument(recordComponent.modifiers().isEmpty(), "record components must not have modifiers"); + } + } + for (TypeName superinterface : superinterfaces) { checkArgument(superinterface != null, "superinterfaces contains null"); } @@ -948,6 +999,14 @@ public TypeSpec build() { fieldSpec.name(), check); } + if (kind == Kind.RECORD) { + checkState( + fieldSpec.modifiers().contains(Modifier.STATIC), + "%s %s.%s must be static", + kind, + name, + fieldSpec.name()); + } } for (MethodSpec methodSpec : methodSpecs) { @@ -995,6 +1054,14 @@ public TypeSpec build() { name, methodSpec.name()); } + if (kind == Kind.RECORD) { + checkState( + !methodSpec.modifiers().contains(Modifier.NATIVE), + "%s %s.%s cannot be native", + kind, + name, + methodSpec.name()); + } } for (TypeSpec typeSpec : typeSpecs) { @@ -1007,7 +1074,11 @@ public TypeSpec build() { kind.implicitTypeModifiers); } - boolean isAbstract = modifiers.contains(Modifier.ABSTRACT) || kind != Kind.CLASS; + boolean isAbstract = + switch (kind) { + case CLASS, RECORD -> modifiers.contains(Modifier.ABSTRACT); + case ENUM, ANNOTATION, INTERFACE -> true; + }; for (MethodSpec methodSpec : methodSpecs) { checkArgument( isAbstract || !methodSpec.modifiers().contains(Modifier.ABSTRACT), diff --git a/javapoet/src/test/java/com/palantir/javapoet/JavaFileTest.java b/javapoet/src/test/java/com/palantir/javapoet/JavaFileTest.java index 2b124e28..e88cf42b 100644 --- a/javapoet/src/test/java/com/palantir/javapoet/JavaFileTest.java +++ b/javapoet/src/test/java/com/palantir/javapoet/JavaFileTest.java @@ -19,6 +19,7 @@ import static org.assertj.core.api.Assertions.assertThatCode; import com.google.testing.compile.CompilationRule; +import java.io.Serializable; import java.util.Collections; import java.util.Date; import java.util.List; @@ -387,6 +388,152 @@ class Taco { """); } + @Test + public void recordOneFieldWithGeneric() { + String source = JavaFile.builder( + "com.palantir.tacos", + TypeSpec.recordBuilder("Taco") + .addTypeVariable(TypeVariableName.get("T")) + .recordConstructor(MethodSpec.constructorBuilder() + .addParameter(ParameterSpec.builder( + ParameterizedTypeName.get( + ClassName.get(List.class), TypeVariableName.get("T")), + "names") + .build()) + .build()) + .build()) + .skipJavaLangImports(true) + .build() + .toString(); + assertThat(source) + .isEqualTo( + """ + package com.palantir.tacos; + + import java.util.List; + + record Taco(List names) { + } + """); + } + + @Test + public void recordOneFieldImplementsInterface() { + String source = JavaFile.builder( + "com.palantir.tacos", + TypeSpec.recordBuilder("Taco") + .recordConstructor(MethodSpec.constructorBuilder() + .addParameter(ParameterSpec.builder(String.class, "name") + .build()) + .build()) + .addSuperinterface(Serializable.class) + .build()) + .skipJavaLangImports(true) + .build() + .toString(); + assertThat(source) + .isEqualTo( + """ + package com.palantir.tacos; + + import java.io.Serializable; + + record Taco(String name) implements Serializable { + } + """); + } + + @Test + public void recordOneFieldWithAnnotation() { + String source = JavaFile.builder( + "com.palantir.tacos", + TypeSpec.recordBuilder("Taco") + .recordConstructor(MethodSpec.constructorBuilder() + .addParameter(ParameterSpec.builder(String.class, "name") + .build()) + .build()) + .addAnnotation(Deprecated.class) + .build()) + .skipJavaLangImports(true) + .build() + .toString(); + assertThat(source) + .isEqualTo( + """ + package com.palantir.tacos; + + @Deprecated + record Taco(String name) { + } + """); + } + + @Test + public void secondaryConstructorRecord() { + String source = JavaFile.builder( + "com.palantir.tacos", + TypeSpec.recordBuilder("Taco") + .recordConstructor(MethodSpec.constructorBuilder() + .addParameter(ParameterSpec.builder(ClassName.get(String.class), "name") + .build()) + .build()) + .addMethod(MethodSpec.constructorBuilder() + .addParameter(TypeName.INT, "number") + .addCode("this($T.toString(number));", ClassName.get(Integer.class)) + .build()) + .build()) + .skipJavaLangImports(true) + .build() + .toString(); + assertThat(source) + .isEqualTo( + """ + package com.palantir.tacos; + + record Taco(String name) { + Taco(int number) { + this(Integer.toString(number)); + } + } + """); + } + + @Test + public void recordWithCompactConstructor() { + ParameterSpec name = + ParameterSpec.builder(ClassName.get(String.class), "name").build(); + String source = JavaFile.builder( + "com.palantir.tacos", + TypeSpec.recordBuilder("Taco") + .recordConstructor(MethodSpec.compactConstructorBuilder() + .addModifiers(Modifier.PUBLIC) + .addParameter(name) + .addCode(CodeBlock.builder() + .beginControlFlow("if ($N.isEmpty())", name) + .addStatement( + "throw new $T()", ClassName.get(IllegalArgumentException.class)) + .endControlFlow() + .build()) + .build()) + .build()) + .skipJavaLangImports(true) + .build() + .toString(); + assertThat(source) + .isEqualTo( + """ + package com.palantir.tacos; + + record Taco(String name) { + public Taco { + if (name.isEmpty()) { + throw new IllegalArgumentException(); + } + } + } + """); + } + @Test public void skipJavaLangImportsWithConflictingClassLast() { // Whatever is used first wins! In this case the Float in java.lang is imported. diff --git a/javapoet/src/test/java/com/palantir/javapoet/TypeSpecTest.java b/javapoet/src/test/java/com/palantir/javapoet/TypeSpecTest.java index 44cdc558..f61c5f9b 100644 --- a/javapoet/src/test/java/com/palantir/javapoet/TypeSpecTest.java +++ b/javapoet/src/test/java/com/palantir/javapoet/TypeSpecTest.java @@ -851,6 +851,133 @@ sealed interface Taco extends Serializable, Comparable permits BeefTaco, C """); } + @Test + public void recordOneField() { + TypeSpec typeSpec = TypeSpec.recordBuilder("Taco") + .recordConstructor(MethodSpec.constructorBuilder() + .addParameter( + ParameterSpec.builder(String.class, "name").build()) + .build()) + .build(); + assertThat(toString(typeSpec)) + .isEqualTo( + """ + package com.palantir.tacos; + + import java.lang.String; + + record Taco(String name) { + } + """); + } + + @Test + public void recordTwoFields() { + TypeSpec typeSpec = TypeSpec.recordBuilder("Taco") + .recordConstructor(MethodSpec.constructorBuilder() + .addParameter( + ParameterSpec.builder(String.class, "name").build()) + .addParameter( + ParameterSpec.builder(Integer.class, "size").build()) + .build()) + .build(); + assertThat(toString(typeSpec)) + .isEqualTo( + """ + package com.palantir.tacos; + + import java.lang.Integer; + import java.lang.String; + + record Taco(String name, Integer size) { + } + """); + } + + @Test + public void recordWithVarArgs() { + TypeSpec typeSpec = TypeSpec.recordBuilder("Taco") + .recordConstructor(MethodSpec.constructorBuilder() + .addParameter( + ParameterSpec.builder(String.class, "name").build()) + .addParameter(ParameterSpec.builder(ArrayTypeName.of(ClassName.get(String.class)), "names") + .build()) + .varargs() + .build()) + .build(); + assertThat(toString(typeSpec)) + .isEqualTo( + """ + package com.palantir.tacos; + + import java.lang.String; + + record Taco(String name, String... names) { + } + """); + } + + @Test + public void recordWithJavadoc() { + TypeSpec typeSpec = TypeSpec.recordBuilder("Taco") + .recordConstructor(MethodSpec.constructorBuilder() + .addParameter(ParameterSpec.builder(String.class, "id") + .addJavadoc("Id of the taco.") + .build()) + .build()) + .addJavadoc("A taco class that stores the id of a taco.") + .build(); + assertThat(toString(typeSpec)) + .isEqualTo( + """ + package com.palantir.tacos; + + import java.lang.String; + + /** + * A taco class that stores the id of a taco. + * @param id Id of the taco. + */ + record Taco(String id) { + } + """); + } + + @Test + public void recordWithAnnotationOnParam() { + TypeSpec typeSpec = TypeSpec.recordBuilder("Taco") + .recordConstructor(MethodSpec.constructorBuilder() + .addParameter(ParameterSpec.builder(String.class, "id") + .addAnnotation(Deprecated.class) + .build()) + .build()) + .build(); + assertThat(toString(typeSpec)) + .isEqualTo( + """ + package com.palantir.tacos; + + import java.lang.Deprecated; + import java.lang.String; + + record Taco(@Deprecated String id) { + } + """); + } + + @Test + public void recordNoField() { + TypeSpec typeSpec = TypeSpec.recordBuilder("Taco").build(); + assertThat(toString(typeSpec)) + .isEqualTo( + """ + package com.palantir.tacos; + + record Taco() { + } + """); + } + @Test public void nestedClasses() { ClassName taco = ClassName.get(tacosPackage, "Combo", "Taco");