Skip to content
This repository was archived by the owner on Feb 26, 2023. It is now read-only.

Commit eda7383

Browse files
Initial commit - Builder annotation works for simple data classes
0 parents  commit eda7383

File tree

21 files changed

+612
-0
lines changed

21 files changed

+612
-0
lines changed

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.gradle
2+
.idea
3+
**/build/
4+
5+
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
6+
!gradle-wrapper.jar

LICENSE.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2018 Thinking Logic Limited
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# kotlin-builder-annotation
2+
A builder annotation for Kotlin interoperability with Java.
3+
This project aims to be a minimal viable replacement for the Lombok @Builder plugin for Kotlin code.
4+
5+
Named constructor parameters with optional values mean that Kotlin code doesn't require the builder pattern any more,
6+
_unless_ you're writing code that will be used by Java projects
7+
(else you consign your java clients to using unhelpful constructors with many parameters).

build.gradle

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
2+
buildscript {
3+
ext.kotlin_version = '1.2.60'
4+
5+
repositories {
6+
mavenCentral()
7+
}
8+
9+
dependencies {
10+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
11+
}
12+
}
13+
14+
15+
allprojects{
16+
apply plugin: "kotlin"
17+
18+
repositories {
19+
mavenCentral()
20+
}
21+
22+
compileKotlin {
23+
kotlinOptions {
24+
jvmTarget = "1.8"
25+
javaParameters = true
26+
}
27+
}
28+
29+
compileTestKotlin {
30+
kotlinOptions {
31+
jvmTarget = "1.8"
32+
javaParameters = true
33+
}
34+
}
35+
36+
dependencies {
37+
compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
38+
}
39+
40+
}

builder-annotation/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
2+
dependencies {
3+
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
4+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.thinkinglogic.builder.annotation
2+
3+
/**
4+
* A lightweight replacement for Lombok's @Builder annotation, decorating a class with @Builder will cause a
5+
* {AnnotatedClassName}Builder class to be generated.
6+
*/
7+
@Retention(AnnotationRetention.SOURCE)
8+
@Target(AnnotationTarget.CLASS)
9+
annotation class Builder {
10+
}

builder-processor/build.gradle

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
apply plugin: 'kotlin-kapt'
2+
3+
dependencies {
4+
implementation project(':builder-annotation')
5+
6+
implementation 'com.squareup:kotlinpoet:1.0.0-RC1'
7+
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
8+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package com.thinkinglogic.builder.processor
2+
3+
import com.squareup.kotlinpoet.*
4+
import com.thinkinglogic.builder.annotation.Builder
5+
import org.jetbrains.annotations.NotNull
6+
import java.io.File
7+
import javax.annotation.processing.*
8+
import javax.lang.model.SourceVersion
9+
import javax.lang.model.element.Element
10+
import javax.lang.model.element.Name
11+
import javax.lang.model.element.TypeElement
12+
import javax.lang.model.type.PrimitiveType
13+
import javax.lang.model.util.ElementFilter.fieldsIn
14+
import javax.tools.Diagnostic.Kind.*
15+
16+
/**
17+
* Kapt processor for the @Builder annotation.
18+
* Constructs a Builder for the annotated class.
19+
*/
20+
@SupportedAnnotationTypes("com.thinkinglogic.builder.annotation.Builder")
21+
@SupportedSourceVersion(SourceVersion.RELEASE_8)
22+
@SupportedOptions(BuilderProcessor.KAPT_KOTLIN_GENERATED_OPTION_NAME)
23+
class BuilderProcessor : AbstractProcessor() {
24+
25+
companion object {
26+
const val KAPT_KOTLIN_GENERATED_OPTION_NAME = "kapt.kotlin.generated"
27+
}
28+
29+
override fun process(annotations: MutableSet<out TypeElement>?, roundEnv: RoundEnvironment): Boolean {
30+
val annotatedElements = roundEnv.getElementsAnnotatedWith(Builder::class.java)
31+
if (annotatedElements.isEmpty()) {
32+
processingEnv.noteMessage { "No classes annotated with @${Builder::class.java.simpleName} in this round ($roundEnv)" }
33+
return false
34+
}
35+
36+
val generatedSourcesRoot = processingEnv.options[KAPT_KOTLIN_GENERATED_OPTION_NAME] ?: run {
37+
processingEnv.errorMessage { "Can't find the target directory for generated Kotlin files." }
38+
return false
39+
}
40+
41+
processingEnv.noteMessage { "Generating Builders for ${annotatedElements.size} classes in $generatedSourcesRoot" }
42+
43+
val sourceRootFile = File(generatedSourcesRoot)
44+
sourceRootFile.mkdir()
45+
46+
annotatedElements.forEach { annotatedClass ->
47+
if (annotatedClass !is TypeElement) {
48+
annotatedClass.errorMessage { "Invalid element type, expected a class" }
49+
return@forEach
50+
}
51+
52+
writeBuilderForClass(annotatedClass, sourceRootFile)
53+
}
54+
55+
return false
56+
}
57+
58+
private fun writeBuilderForClass(annotatedClass: TypeElement, sourceRootFile: File) {
59+
val packageName = processingEnv.elementUtils.getPackageOf(annotatedClass).toString()
60+
val builderClassName = "${annotatedClass.simpleName}Builder"
61+
62+
processingEnv.noteMessage { "Writing $packageName.$builderClassName" }
63+
64+
val classBuilder = TypeSpec.classBuilder(builderClassName)
65+
val builderClass = ClassName(packageName, builderClassName)
66+
val fieldsInClass = fieldsIn(processingEnv.elementUtils.getAllMembers(annotatedClass))
67+
68+
fieldsInClass.forEach { field ->
69+
processingEnv.noteMessage { "Adding field: $field" }
70+
classBuilder.addProperty(field.asProperty())
71+
classBuilder.addFunction(field.asSetterFunctionReturning(builderClass))
72+
}
73+
74+
classBuilder.addFunction(createBuildFunction(fieldsInClass, annotatedClass.simpleName))
75+
76+
FileSpec.builder(packageName, builderClassName)
77+
.addType(classBuilder.build())
78+
.build()
79+
.writeTo(sourceRootFile)
80+
}
81+
82+
private fun createBuildFunction(fieldInClass: List<Element>, returnType: Name): FunSpec {
83+
val buildFunctionContent = StringBuilder("return $returnType(")
84+
val iterator = fieldInClass.listIterator()
85+
while (iterator.hasNext()) {
86+
val field = iterator.next()
87+
buildFunctionContent.appendln().append(" ${field.simpleName} = ${field.simpleName}")
88+
if (!field.isNullable()) {
89+
buildFunctionContent.append("!!")
90+
}
91+
if (iterator.hasNext()) {
92+
buildFunctionContent.append(",")
93+
}
94+
}
95+
buildFunctionContent.appendln().append(")").appendln()
96+
97+
return FunSpec.builder("build")
98+
.addCode(buildFunctionContent.toString())
99+
.build()
100+
}
101+
102+
private fun Element.className(): ClassName {
103+
var className = this.asTypeElement().asClassName()
104+
if (className.packageName == "java.lang") {
105+
className = Class.forName(className.canonicalName).kotlin.asClassName()
106+
}
107+
return className
108+
}
109+
110+
private fun Element.asTypeElement(): TypeElement {
111+
val element = processingEnv.typeUtils.asElement(this.asType()) ?: processingEnv.typeUtils.boxedClass(this.asType() as PrimitiveType?)
112+
return element as TypeElement
113+
}
114+
115+
private fun Element.isNullable(): Boolean {
116+
if (this.asType() is PrimitiveType) {
117+
return false
118+
}
119+
return !this.annotationMirrors
120+
.map { it.annotationType.toString() }
121+
.toSet()
122+
.contains(NotNull::class.java.name)
123+
}
124+
125+
private fun Element.asProperty(): PropertySpec =
126+
PropertySpec.varBuilder(simpleName.toString(), className().asNullable(), KModifier.PRIVATE)
127+
.initializer("null")
128+
.build()
129+
130+
private fun Element.asSetterFunctionReturning(returnType: ClassName): FunSpec {
131+
val fieldClassName = className()
132+
val parameterClass = (if (isNullable()) fieldClassName.asNullable() else fieldClassName)
133+
return FunSpec.builder(simpleName.toString())
134+
.addParameter(ParameterSpec.builder("value", parameterClass).build())
135+
.returns(returnType)
136+
.addCode("return apply { $simpleName = value }")
137+
.build()
138+
}
139+
140+
private fun Element.errorMessage(message: () -> String) {
141+
processingEnv.messager.printMessage(ERROR, message(), this)
142+
}
143+
}
144+
145+
private fun ProcessingEnvironment.errorMessage(message: () -> String) {
146+
this.messager.printMessage(ERROR, message())
147+
}
148+
149+
private fun ProcessingEnvironment.noteMessage(message: () -> String) {
150+
this.messager.printMessage(NOTE, message())
151+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
com.thinkinglogic.builder.processor.BuilderProcessor

client/build.gradle

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
apply plugin: 'kotlin-kapt'
2+
3+
dependencies {
4+
compile project(':builder-annotation')
5+
6+
kapt project(':builder-processor')
7+
8+
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
9+
10+
testCompile group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: '5.2.0'
11+
testCompile group: 'com.willowtreeapps.assertk', name: 'assertk', version: '0.10'
12+
13+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.thinkinglogic.example
2+
3+
import java.util.*
4+
5+
data class CollectionsDataClass(
6+
val listOfStrings: List<String>,
7+
val arrayOfStrings: Array<String>,
8+
val mapOfStrings: Map<String, String>
9+
10+
) {
11+
override fun equals(other: Any?): Boolean {
12+
if (this === other) return true
13+
if (javaClass != other?.javaClass) return false
14+
15+
other as CollectionsDataClass
16+
17+
if (listOfStrings != other.listOfStrings) return false
18+
if (!Arrays.equals(arrayOfStrings, other.arrayOfStrings)) return false
19+
if (mapOfStrings != other.mapOfStrings) return false
20+
21+
return true
22+
}
23+
24+
override fun hashCode(): Int {
25+
var result = listOfStrings.hashCode()
26+
result = 31 * result + Arrays.hashCode(arrayOfStrings)
27+
result = 31 * result + mapOfStrings.hashCode()
28+
return result
29+
}
30+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.thinkinglogic.example
2+
3+
data class ConstructorArgDataClass(
4+
val nonPropertyParam: String,
5+
val otherProperty: String
6+
) {
7+
private val propertyValue = nonPropertyParam
8+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.thinkinglogic.example
2+
3+
import com.thinkinglogic.builder.annotation.Builder
4+
import java.time.LocalDate
5+
6+
@Builder
7+
data class SimpleDataClass(
8+
val notNullString: String = "withDefaultValue",
9+
val nullableString: String?,
10+
val notNullLong: Long,
11+
val nullableLong: Long?,
12+
val date: LocalDate
13+
)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.thinkinglogic.example
2+
3+
import assertk.assert
4+
import assertk.assertions.isEqualTo
5+
import org.junit.jupiter.api.Test
6+
import java.time.LocalDate
7+
8+
internal class SimpleDataClassTest {
9+
10+
@Test
11+
fun `builder should create object with correct properties`() {
12+
// given
13+
val expected = SimpleDataClass(
14+
nullableString = null,
15+
notNullLong = 123,
16+
nullableLong = 345,
17+
date = LocalDate.now()
18+
)
19+
20+
// when
21+
val actual = SimpleDataClassBuilder()
22+
.notNullString(expected.notNullString)
23+
.notNullLong(expected.notNullLong)
24+
.nullableLong(expected.nullableLong)
25+
.date(expected.date)
26+
.build()
27+
28+
// then
29+
assert(actual).isEqualTo(expected)
30+
}
31+
32+
// TODO create test that confirms a default value for notNullString property is correctly set
33+
}

gradle.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
kapt.verbose=true

gradle/wrapper/gradle-wrapper.jar

53.1 KB
Binary file not shown.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#Sun Aug 05 16:37:36 BST 2018
2+
distributionBase=GRADLE_USER_HOME
3+
distributionPath=wrapper/dists
4+
zipStoreBase=GRADLE_USER_HOME
5+
zipStorePath=wrapper/dists
6+
distributionUrl=https\://services.gradle.org/distributions/gradle-4.9-all.zip

0 commit comments

Comments
 (0)