From 1d9e6b5b7be39eda87873d285a55fbb94cdaa229 Mon Sep 17 00:00:00 2001 From: Stephane Nicoll Date: Fri, 9 Jun 2023 10:38:13 +0200 Subject: [PATCH] Introduce class name Closes gh-1425 --- .../generator/language/ClassName.java | 200 ++++++++++++++++++ .../generator/language/CodeBlock.java | 14 +- .../src/test/java/com/example/Example.java | 29 +++ .../generator/language/ClassNameTests.java | 169 +++++++++++++++ .../generator/language/CodeBlockTests.java | 7 + 5 files changed, 414 insertions(+), 5 deletions(-) create mode 100644 initializr-generator/src/main/java/io/spring/initializr/generator/language/ClassName.java create mode 100644 initializr-generator/src/test/java/com/example/Example.java create mode 100644 initializr-generator/src/test/java/io/spring/initializr/generator/language/ClassNameTests.java diff --git a/initializr-generator/src/main/java/io/spring/initializr/generator/language/ClassName.java b/initializr-generator/src/main/java/io/spring/initializr/generator/language/ClassName.java new file mode 100644 index 00000000..6462bd8f --- /dev/null +++ b/initializr-generator/src/main/java/io/spring/initializr/generator/language/ClassName.java @@ -0,0 +1,200 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.initializr.generator.language; + +import java.util.List; +import java.util.Objects; + +import javax.lang.model.SourceVersion; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Type reference abstraction to refer to a {@link Class} that is not available on the + * classpath. + * + * @author Stephane Nicoll + */ +public final class ClassName { + + private static final List PRIMITIVE_NAMES = List.of("boolean", "byte", "short", "int", "long", "char", + "float", "double", "void"); + + private final String packageName; + + private final String simpleName; + + private final ClassName enclosingType; + + private String canonicalName; + + private ClassName(String packageName, String simpleName, ClassName enclosingType) { + this.packageName = packageName; + this.simpleName = simpleName; + this.enclosingType = enclosingType; + } + + /** + * Create a {@link ClassName} based on the specified fully qualified name. The format + * of the class name must follow {@linkplain Class#getName()}, in particular inner + * classes should be separated by a {@code $}. + * @param fqName the fully qualified name of the class + * @return a class name + */ + public static ClassName of(String fqName) { + Assert.notNull(fqName, "'className' must not be null"); + if (!isValidClassName(fqName)) { + throw new IllegalStateException("Invalid class name '" + fqName + "'"); + } + if (!fqName.contains("$")) { + return createClassName(fqName); + } + String[] elements = fqName.split("(? type) { + return of(type.getName()); + } + + /** + * Return the fully qualified name. + * @return the reflection target name + */ + public String getName() { + ClassName enclosingType = getEnclosingType(); + String simpleName = getSimpleName(); + return (enclosingType != null) ? (enclosingType.getName() + '$' + simpleName) + : addPackageIfNecessary(simpleName); + } + + /** + * Return the package name. + * @return the package name + */ + public String getPackageName() { + return this.packageName; + } + + /** + * Return the {@linkplain Class#getSimpleName() simple name}. + * @return the simple name + */ + public String getSimpleName() { + return this.simpleName; + } + + /** + * Return the enclosing class name, or {@code null} if this instance does not have an + * enclosing type. + * @return the enclosing type, if any + */ + public ClassName getEnclosingType() { + return this.enclosingType; + } + + /** + * Return the {@linkplain Class#getCanonicalName() canonical name}. + * @return the canonical name + */ + public String getCanonicalName() { + if (this.canonicalName == null) { + StringBuilder names = new StringBuilder(); + buildName(this, names); + this.canonicalName = addPackageIfNecessary(names.toString()); + } + return this.canonicalName; + } + + private boolean isPrimitive() { + return isPrimitive(getSimpleName()); + } + + private static boolean isPrimitive(String name) { + return PRIMITIVE_NAMES.stream().anyMatch(name::startsWith); + } + + private String addPackageIfNecessary(String part) { + if (this.packageName.isEmpty() || this.packageName.equals("java.lang") && isPrimitive()) { + return part; + } + return this.packageName + '.' + part; + } + + private static boolean isValidClassName(String className) { + for (String s : className.split("\\.", -1)) { + String candidate = s.replace("[", "").replace("]", ""); + if (!SourceVersion.isIdentifier(candidate)) { + return false; + } + } + return true; + } + + private static ClassName createClassName(String className) { + int i = className.lastIndexOf('.'); + if (i != -1) { + return new ClassName(className.substring(0, i), className.substring(i + 1), null); + } + else { + String packageName = (isPrimitive(className)) ? "java.lang" : ""; + return new ClassName(packageName, className, null); + } + } + + private static void buildName(ClassName className, StringBuilder sb) { + if (className == null) { + return; + } + String typeName = (className.getEnclosingType() != null) ? "." + className.getSimpleName() + : className.getSimpleName(); + sb.insert(0, typeName); + buildName(className.getEnclosingType(), sb); + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof ClassName className)) { + return false; + } + return getCanonicalName().equals(className.getCanonicalName()); + } + + @Override + public int hashCode() { + return Objects.hash(getCanonicalName()); + } + + @Override + public String toString() { + return getCanonicalName(); + } + +} diff --git a/initializr-generator/src/main/java/io/spring/initializr/generator/language/CodeBlock.java b/initializr-generator/src/main/java/io/spring/initializr/generator/language/CodeBlock.java index 14ba731a..717ff407 100644 --- a/initializr-generator/src/main/java/io/spring/initializr/generator/language/CodeBlock.java +++ b/initializr-generator/src/main/java/io/spring/initializr/generator/language/CodeBlock.java @@ -37,8 +37,8 @@ import org.springframework.util.ClassUtils; * that. Emit {@code "null"} if the value is {@code null}. Does not handle multi-line * strings. *
  • {@code $T} emits a type reference. Arguments for types may be plain - * {@linkplain Class classes}, fully qualified class names, and fully qualified - * functions.
  • + * {@linkplain Class classes}, {@linkplain ClassName class names}, fully qualified class + * names, and fully qualified functions. *
  • {@code $$} emits a dollar sign. *
  • {@code $]} ends a statement and emits the configured * {@linkplain FormattingOptions#statementSeparator() statement separator}. @@ -253,9 +253,13 @@ public final class CodeBlock { this.imports.add(type.getName()); return type.getSimpleName(); } - if (arg instanceof String className) { - this.imports.add(className); - return ClassUtils.getShortName(className); + if (arg instanceof ClassName className) { + this.imports.add(className.getName()); + return className.getSimpleName(); + } + if (arg instanceof String fqName) { + this.imports.add(fqName); + return ClassUtils.getShortName(fqName); } throw new IllegalArgumentException("Failed to extract type from '%s'".formatted(arg)); } diff --git a/initializr-generator/src/test/java/com/example/Example.java b/initializr-generator/src/test/java/com/example/Example.java new file mode 100644 index 00000000..f2bd5a08 --- /dev/null +++ b/initializr-generator/src/test/java/com/example/Example.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example; + +public class Example { + + public static class Inner { + + public static class Nested { + + } + + } + +} diff --git a/initializr-generator/src/test/java/io/spring/initializr/generator/language/ClassNameTests.java b/initializr-generator/src/test/java/io/spring/initializr/generator/language/ClassNameTests.java new file mode 100644 index 00000000..7c4378ef --- /dev/null +++ b/initializr-generator/src/test/java/io/spring/initializr/generator/language/ClassNameTests.java @@ -0,0 +1,169 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.initializr.generator.language; + +import java.util.stream.Stream; + +import com.example.Example; +import com.example.Example.Inner; +import com.example.Example.Inner.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link ClassName}. + * + * @author Stephane Nicoll + */ +class ClassNameTests { + + @Test + void classNameWithTopLevelClassName() { + classNameWithTopLevelClass(ClassName.of("com.example.Example")); + } + + @Test + void classNameWithTopLevelClass() { + classNameWithTopLevelClass(ClassName.of(Example.class)); + } + + private void classNameWithTopLevelClass(ClassName className) { + assertThat(className.getName()).isEqualTo("com.example.Example"); + assertThat(className.getCanonicalName()).isEqualTo("com.example.Example"); + assertThat(className.getPackageName()).isEqualTo("com.example"); + assertThat(className.getSimpleName()).isEqualTo("Example"); + assertThat(className.getEnclosingType()).isNull(); + } + + @Test + void classNameWithInnerClassName() { + classNameWithInnerClass(ClassName.of("com.example.Example$Inner")); + } + + @Test + void classNameWithInnerClass() { + classNameWithInnerClass(ClassName.of(Inner.class)); + } + + private void classNameWithInnerClass(ClassName className) { + assertThat(className.getName()).isEqualTo("com.example.Example$Inner"); + assertThat(className.getCanonicalName()).isEqualTo("com.example.Example.Inner"); + assertThat(className.getPackageName()).isEqualTo("com.example"); + assertThat(className.getSimpleName()).isEqualTo("Inner"); + assertThat(className.getEnclosingType()).satisfies((enclosingType) -> { + assertThat(enclosingType.getCanonicalName()).isEqualTo("com.example.Example"); + assertThat(enclosingType.getPackageName()).isEqualTo("com.example"); + assertThat(enclosingType.getSimpleName()).isEqualTo("Example"); + assertThat(enclosingType.getEnclosingType()).isNull(); + }); + } + + @Test + void classNameWithNestedInnerClassName() { + classNameWithNestedInnerClass(ClassName.of("com.example.Example$Inner$Nested")); + } + + @Test + void classNameWithNestedInnerClass() { + classNameWithNestedInnerClass(ClassName.of(Nested.class)); + } + + private void classNameWithNestedInnerClass(ClassName className) { + assertThat(className.getName()).isEqualTo("com.example.Example$Inner$Nested"); + assertThat(className.getCanonicalName()).isEqualTo("com.example.Example.Inner.Nested"); + assertThat(className.getPackageName()).isEqualTo("com.example"); + assertThat(className.getSimpleName()).isEqualTo("Nested"); + assertThat(className.getEnclosingType()).satisfies((enclosingType) -> { + assertThat(enclosingType.getCanonicalName()).isEqualTo("com.example.Example.Inner"); + assertThat(enclosingType.getPackageName()).isEqualTo("com.example"); + assertThat(enclosingType.getSimpleName()).isEqualTo("Inner"); + assertThat(enclosingType.getEnclosingType()).satisfies((parentEnclosingType) -> { + assertThat(parentEnclosingType.getCanonicalName()).isEqualTo("com.example.Example"); + assertThat(parentEnclosingType.getPackageName()).isEqualTo("com.example"); + assertThat(parentEnclosingType.getSimpleName()).isEqualTo("Example"); + assertThat(parentEnclosingType.getEnclosingType()).isNull(); + }); + }); + } + + @ParameterizedTest + @MethodSource("primitivesAndPrimitivesArray") + void primitivesAreHandledProperly(ClassName className, String expectedName) { + assertThat(className.getName()).isEqualTo(expectedName); + assertThat(className.getCanonicalName()).isEqualTo(expectedName); + assertThat(className.getPackageName()).isEqualTo("java.lang"); + } + + static Stream primitivesAndPrimitivesArray() { + return Stream.of(Arguments.of(ClassName.of("boolean"), "boolean"), Arguments.of(ClassName.of("byte"), "byte"), + Arguments.of(ClassName.of("short"), "short"), Arguments.of(ClassName.of("int"), "int"), + Arguments.of(ClassName.of("long"), "long"), Arguments.of(ClassName.of("char"), "char"), + Arguments.of(ClassName.of("float"), "float"), Arguments.of(ClassName.of("double"), "double"), + Arguments.of(ClassName.of("boolean[]"), "boolean[]"), Arguments.of(ClassName.of("byte[]"), "byte[]"), + Arguments.of(ClassName.of("short[]"), "short[]"), Arguments.of(ClassName.of("int[]"), "int[]"), + Arguments.of(ClassName.of("long[]"), "long[]"), Arguments.of(ClassName.of("char[]"), "char[]"), + Arguments.of(ClassName.of("float[]"), "float[]"), Arguments.of(ClassName.of("double[]"), "double[]")); + } + + @ParameterizedTest + @MethodSource("arrays") + void arraysHaveSuitableReflectionTargetName(ClassName typeReference, String expectedName) { + assertThat(typeReference.getName()).isEqualTo(expectedName); + } + + static Stream arrays() { + return Stream.of(Arguments.of(ClassName.of("java.lang.Object[]"), "java.lang.Object[]"), + Arguments.of(ClassName.of("java.lang.Integer[]"), "java.lang.Integer[]"), + Arguments.of(ClassName.of("com.example.Test[]"), "com.example.Test[]")); + } + + @Test + void classNameInRootPackage() { + ClassName type = ClassName.of("MyRootClass"); + assertThat(type.getCanonicalName()).isEqualTo("MyRootClass"); + assertThat(type.getPackageName()).isEmpty(); + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = { "com.example.Tes(t", "com.example..Test" }) + void classNameWithInvalidClassName(String invalidClassName) { + assertThatIllegalStateException().isThrownBy(() -> ClassName.of(invalidClassName)) + .withMessageContaining("Invalid class name"); + } + + @Test + void equalsWithIdenticalNameIsTrue() { + assertThat(ClassName.of(String.class)).isEqualTo(ClassName.of("java.lang.String")); + } + + @Test + void equalsWithNonClassNameIsFalse() { + assertThat(ClassName.of(String.class)).isNotEqualTo("java.lang.String"); + } + + @Test + void toStringUsesCanonicalName() { + assertThat(ClassName.of(String.class)).hasToString("java.lang.String"); + } + +} diff --git a/initializr-generator/src/test/java/io/spring/initializr/generator/language/CodeBlockTests.java b/initializr-generator/src/test/java/io/spring/initializr/generator/language/CodeBlockTests.java index 7b65f00d..ed50c54e 100644 --- a/initializr-generator/src/test/java/io/spring/initializr/generator/language/CodeBlockTests.java +++ b/initializr-generator/src/test/java/io/spring/initializr/generator/language/CodeBlockTests.java @@ -125,6 +125,13 @@ class CodeBlockTests { @Test void codeBlockWithTypePlaceholderAndClassNameAddsImport() { + CodeBlock code = CodeBlock.of("return $T.truncate(myString)", ClassName.of(StringUtils.class)); + assertThat(writeJava(code)).isEqualTo("return StringUtils.truncate(myString)"); + assertThat(code.getImports()).containsExactly(StringUtils.class.getName()); + } + + @Test + void codeBlockWithTypePlaceholderAndFullyQualifiedClassNameAddsImport() { CodeBlock code = CodeBlock.of("return $T.truncate(myString)", "com.example.StringUtils"); assertThat(writeJava(code)).isEqualTo("return StringUtils.truncate(myString)"); assertThat(code.getImports()).containsExactly("com.example.StringUtils");