Declarative Starlark code generator written in Kotlin
APACHE-2.0 License
Pendant — is a declarative Starlark code generator written in Kotlin. Use Kotlin DSL that looks and feels like Starlark syntax, for generating Bazel scripts in an intuitive and type-safe fashion.
Add the following dependencies to your Gradle module to start using Pendant.
dependencies {
// Starlark code generator.
implementation("io.morfly.pendant:pendant-starlark:x.y.z")
// Kotlin DSL extension with Bazel functions.
implementation("io.morfly.pendant:pendant-library-bazel:x.y.z")
// Optional. Generator for custom Starlark functions.
ksp("io.morfly.pendant:pendant-library-compiler:x.y.z")
}
To generate Starlark files Pendant provides a Kotlin DSL that replicates Starlark syntax as close as possible.
Here is how you can generate a BUILD.bazel
file with Pendant.
// Kotlin
val builder = BUILD.bazel {
load("@io_bazel_rules_kotlin//kotlin:android.bzl", "kt_android_library")
kt_android_library(
name = "my-library",
srcs = glob("src/main/kotlin/**/*.kt"),
custom_package = "io.morfly.mylibrary",
manifest = "src/main/AndroidManifest.xml",
resource_files = glob(["src/main/res/**"]),
)
}
val file = builder.build()
file.write("path/in/file/system")
As a result, a BUILD
file with the following content is generated. Pendant takes care of the code formatting, so you
don't have to do it yourself.
# Generated Starlark
load("@io_bazel_rules_kotlin//kotlin:android.bzl", "kt_android_library")
kt_android_library(
name = "my-library",
srcs = glob("src/main/kotlin/**/*.kt"),
custom_package = "io.morfly.mylibrary",
manifest = "src/main/AndroidManifest.xml",
resource_files = glob(["src/main/res/**"]),
)
Pendant provides an API for generating different types of Starlark files. Once entered the file context, you can use the Kotlin DSL to generate corresponding Starlark statements. Depending on the file type, a different set of Starlark syntax features and functions is available.
To generate BUILD.bazel
files, the following expression must be used.
val builder = BUILD.bazel { ... }
Or a shorter form to produce BUILD
files.
val builder = BUILD { ... }
Similarly, Pendant provides an API for generating WORKSPACE.bazel
files.
val builder = WORKSPACE.bazel { ... }
Or a shorter form to produce WORKSPACE
files.
val builder = WORKSPACE { ... }
Additionally, it is possible to generate files with .bzl
extension.
val builder = "starlark_file".bzl { ... }
Each function demonstrated above returns a FileContext
instance that serves as a file builder. In order to write it to
file it must be first built using build()
function.
val file = builder.build()
Finally, it could be written to file using the API below.
StarlarkFileWriter.write("path/in/file/system", file)
// or
file.write("path/in/file/system")
Alternatively, the formatted contents of a generated file could be returned as a string.
val starlarkCode: String = StarlarkFileFormatter.format(file)
// or
val starlarkCode: String = file.format()
Now, the most important part. In this section we will take a closer look at Kotlin DSL components that represent corresponding Starlark syntax elements, for code generation.
Variable declaration and assignment is an essential feature of Starlark language. Use by
operator to do it.
You might ask, why aren't we using
=
operator in this case? The latter operator will perform a variable assignment operation while compiling the Kotlin code. However, what we need is generating the statement in Starlark rather than executing it in Kotlin.
// Kotlin
val NAME by "app"
# Generated Starlark
NAME = "app"
What's important is that this operation is type safe, meaning NAME
is a variable of string type on the Kotlin DSL
level and could be used further accordingly.
In case the name of the variable is set dynamically, during runtime, but still needs to be referenced in further code, the following syntax is used.
// Kotlin
val VARIABLE by "VARIABLE_$i" `=` "value"
# Generated Starlark
VARIABLE_1 `=` "value"
One of the syntax elements of Starlark are list expressions. There is no equivalent for such an expression in Kotlin.
However, the list[]
function could be used to achieve the same result.
// Kotlin
val SRCS by list["io/morfly/Main.kt"]
# Generated Starlark
SRCS = ["io/morfly/Main.kt"]
Regular listOf
function from Kotlin standard library also works perfectly well. However, having list[]
function is
convenient when you're copy-pasting actual Starlark code in your Kotlin file. This way you would need to do less editing
to make it compilable in your code generator program.
Similarly to list expressions, Kotlin does not have dictionary expressions in its syntax. However, the dict
function
could be used to achieve the same result.
// Kotlin
val MANIFEST_VALUES by dict { "minSdkVersion" to "23" }
# Generated Starlark
MANIFEST_VALUES = { "minSdkVersion" : "23" }
You might notice that to
operator is used to map dictionary values. However, this is not a usual to
function from
Kotlin standard library but a more powerful version.
For example, you could use composite keys and values with concatenation operation.
// Kotlin
val MANIFEST_VALUES by dict { "minSdk" `+` "Version" to "2" `+` "3" }
# Generated Starlark
MANIFEST_VALUES = { "minSdk" + "Version" : "2" + "3" }
In addition, in some cases it's possible to use a shorted form of Kotlin DSL for dictionary expressions. If followed
by `=`
or `+`
operators (with backticks) dict
keyword could be omitted.
// Kotlin
val MANIFEST_VALUES by dict { } `+` { "minSdkVersion" to "23" }
android_binary {
"manifest_values" `=` { "minSdkVersion" to "23" }
}
# Generated Starlark
MANIFEST_VALUES = {} + { "minSdkVersion": "23" }
android_binary(
name = "app",
manifest_values = { "minSdkVersion": "23" },
)
Using `+`
function with backticks you could generate concatenation expressions.
You might ask, why aren't we using a regular
+
operator in this case? The latter operator will perform a variable assignment operation while compiling the Kotlin code. However, what we need is generating the statement in Starlark rather than executing it in Kotlin.
// Kotlin
val ARTIFACTS by list["@maven//:androidx_compose_runtime_runtime"]
val DEPS by ARTIFACTS `+` list["//my-library"]
# Generated Starlark
ARTIFACTS = ["@maven//:androidx_compose_runtime_runtime"]
DEPS = ARTIFACTS + ["//my-library"]
As you can see, you could use concatenations with varouus types of expressions, like list expressions, variable references, list comprehensions, etc. This operation is type-safe in all these cases.
There are different ways to generate a function call with Pendant.
The easiest way is to use Starlark or Bazel functions available directly in Pendant.
// Kotlin
android_binary(
name = "app",
srcs = glob("src/main/kotlin/**/*.kt"),
deps = ARTIFACTS `+` ["//my-library"],
manifest = "src/main/AndroidManifest.xml"
)
Each function in the library is available in 2 variations: with round brackets ()
, and with curly brackets {}
.
The latter is especially useful if you need more customization.
For example, you could use custom parameters which are not part of Pendant library.
// Kotlin
android_binary {
name = "app"
"manifest_values" `=` { "minSdkVersion" to "23" }
}
Moreover, you could declare calls of functions which are completely absent in Pendant library like shown below.
// Kotlin
"android_binary" {
"name" `=` "app"
"manifest_values" `=` { "minSdkVersion" to "23" }
}
One more bonus of generating function calls using curly brackets {}
is that it preserves the order of passed arguments
the way you specify them.
Alternatively, dynamic function calls could be used for functions with no arguments.
"glob"()
So far we've seen how to generate function calls as standalone statements, meaning they don't return any value.
Additionally, Pendant allows generating functions as expressions that return values.
// Kotlin
val SRCS by glob("src/main/kotlin/**/*.kt")
# Generated Starlark
SRCS = glob(["src/main/kotlin/**/*.kt"])
You could use dynamic API for these types of functions as well. Make sure to explicitly specify the return type.
// Kotlin
import io.morfly.pendant.starlark.lang.feature.invoke
val SRCS by "glob"<ListType<StringType>>("src/main/kotlin/**/*.kt")
# Generated Starlark
SRCS = glob(["src/main/kotlin/**/*.kt"])
You might need to manually import the
io.morfly.pendant.starlark.lang.feature.invoke
function from Pendant for dynamic function calls with return values.
If you need to generate a function call with named arguments, use curly brackets {}
.
// Kotlin
import io.morfly.pendant.starlark.lang.feature.invoke
val SRCS by "glob"<ListType<StringType>> {
"include" `=` list["src/main/kotlin/**/*.kt"]
}
# Generated Starlark
SRCS = glob(include = ["src/main/kotlin/**/*.kt"])
Dynamic function calls that return values rely on context receivers, a feature introduced in recent versions of Kotlin. If you need to use it as part of Gradle plugin or scripts, it might not be supported, as Gradle uses older Kotlin versions.
Pendant also allows you to generate a Kotlin DSL for custom Starlark functions. Learn how to generate Kotlin DSL for custom Starlark functions in a corresponding section.
Another powerful Starlark feature for building lists is list comprehensions. Use combination of `in`
and take
operators to generate them with Pendant.
// Kotlin
val CLASSES by list["MainActivity", "MainViewModel"]
val SRCS by "name" `in` CLASSES take { name -> name `+` ".kt" }
# Generated Starlark
CLASSES = ["MainActivity", "MainViewModel"]
SRCS = [name + ".kt" for name in classes]
Additionally, you could use use `for
operator for nested comprehensions.
// Kotlin
val MATRIX by list[
list[1, 2],
list[3, 4]
]
val NUMBERS by "list" `in` MATRIX `for` { list ->
"number" `in` list take { number -> number }
}
# Generated Starlark
MATRIX = [
[1, 2],
[3, 4]
]
NUMBERS = [number for list in MATRIX for number in list]
Alternatively, Pendant supports another variation of nested comprehensions as shown below.
import io.morfly.pendant.starlark.lang.feature.invoke
val RANGE by "range"<ListType<StringType>>(5)
val SRCS by "i" `in` RANGE take { "j" `in` RANGE take { j -> j } }
# Generated Starlark
RANGE = range(5)
SRCS = [
[j for j in RANGE]
for i in RANGE
]
To generate Starlark slices use the following syntax.
// Kotlin
"abc.kt"[0..-3]
# Generated Starlark
"abc.kt"[0:-3]
// Kotlin
load("@rules_java//java:defs.bzl", "java_binary")
# Generated Starlark
load("@rules_java//java:defs.bzl", "java_binary")
If you need to reference the values impored with load
you could use of
function and specify the types of the
corresponding values.
// Kotlin
val (DAGGER_ARTIFACTS, DAGGER_REPOSITORIES) = load(
"@dagger//:workspace_defs.bzl",
"DAGGER_ARTIFACTS", "DAGGER_REPOSITORIES"
).of<ListType<StringType>, ListType<StringType>>()
maven_install(
artifacts = DAGGER_ARTIFACTS,
repositories = DAGGER_REPOSITORIES
)
# Generated Starlark
load("@dagger//:workspace_defs.bzl", "DAGGER_ARTIFACTS", "DAGGER_REPOSITORIES")
maven_install(
artifacts = DAGGER_ARTIFACTS,
repositories = DAGGER_REPOSITORIES
)
Alternatively, you could manually instantiate the reference with the needed type and name.
// Kotlin
load("@dagger//:workspace_defs.bzl", "DAGGER_ARTIFACTS", "DAGGER_REPOSITORIES")
maven_install(
artifacts = ListReference<StringType>("DAGGER_ARTIFACTS"),
repositories = ListReference<StringType>("DAGGER_REPOSITORIES")
)
You can
use StringReference
, NumberReference
, BooleanReference
, ListReference
, DicrionaryReference
, TupleReference
or AnyReference
to refer to imported values.
If you need more freedom with code generation or formatting you could always inject raw strings as part of the generated code.
To do this use +
unary plus operator or raw()
extension function on a string that represents a code.
// Kotlin
+"""
SRCS = glob(["src/main/kotlin/**/*.kt"])
""".trimIndent()
// Kotlin
"""
SRCS = glob(["src/main/kotlin/**/*.kt"])
""".trimIndent().raw()
# Generated Starlark
SRCS = glob(["src/main/kotlin/**/*.kt"])
Modifiers is a flexible mechanism that allows you to externally modify a file builder outside of its body.
Each Kotlin DSL element with a body surrounded by curly brackets {}
could be modified.
To do so, you need to assign it a unique _id
.
// Kotlin
val builder = BUILD.bazel {
_id = "build_file"
android_library {
_id = "android_library_target"
name = "my-library"
deps = list["//another-library"]
}
}
Then, use onContext
API to inject additional code in the corresponding DSL context.
// Kotlin
builder.onContext<BuildContext>(id = "build_file") {
android_binary(
name = "app",
deps = list[":my-library"]
)
}
// Kotlin
builder.onContext<AndroidLibraryContext>(id = "android_library_target") {
visibility = list["//visibility:public"]
deps = list["@maven//:androidx_compose_runtime_runtime"]
}
# Generated Starlark
android_library(
name = "my-library"
deps = ["//another-library"] + ["@maven//:androidx_compose_runtime_runtime"],
)
The code added by modifiers is always added at the end of the context block. However, with checkpoints you can precisely control where the code from modifiers is injected.
// Kotlin
val builder = BUILD.bazel {
_id = "build_file"
val DEPS by list["@maven//:androidx_compose_runtime_runtime"]
_checkpoint("middle")
android_library(
name = "my-library",
deps = DEPS
)
}
// Kotlin
builder.onContext<BuildContext>(id = "build_file", checkpoint = "middle") {
android_binary(
name = "app",
deps = list["my-library"]
)
}
# Generated Starlark
DEPS = ["@maven//:androidx_compose_runtime_runtime"]
android_binary(
name = "app",
deps = ["my-library"],
)
android_library(
name = "my-library",
deps = DEPS,
)
The Kotlin DSL for Starlark/Bazel library functions in Pendant is generated using a library generation API. In fact, you can generate an additional DSL for any custom function you like.
To do that, make sure you add Pendant symbol processor to your dependencies.
dependencies {
ksp("io.morfly.pendant:pendant-library-compiler:x.y.z")
}
Declare an interface, where each its field will represent a corresponding argument of a function you generate. Then,
annotate it with the @LibraryFunction
as shown below.
// Kotlin
@LibraryFunction(
name = "custom_android_binary",
scope = [FunctionScope.Build],
kind = FunctionKind.Statement
)
interface CustomAndroidBinary {
@Argument(required = true)
val name: Name
val srcs: ListType<Label?>?
val custom_package: StringType?
}
// Kotlin
val builder = BUILD {
custom_android_binary(
name = "app",
custom_package = "io.morfly.pendant"
)
}
When using @LibraryFunction
annotation, you need to specify the following arguments:
Param | Description |
---|---|
name |
Name of a generated function both in Kotlin DSL and in Starlark |
scope |
Configure in what types of files the Kotlin DSL function could be used. Use FunctionScope.Build , FunctionScope.Workspace or FunctionScope.Starlark for .bzl files |
kind |
Configure if the Kotlin DSL function generates a Starlark function call as FunctionKind.Statement or as FunctionKind.Expression
|
brackets |
Optional. Configure what kind of Kotlin DSL functions will be generated. Either with BracketsKind.Round () , BracketsKind.Curly {} or both |
Optionally you could annotate an argument with @Argument
for additional configuration.
When using @Argument
annotation, you can specify the following arguments:
Param | Description |
---|---|
name |
Optional. Name of the generated Starlark function call |
required |
Optional. Configure if the argument mandatory in the generated Kotlin DSL |
variadic |
Optional. Configure an argument of ListType to be a vararg in Kotlin DSL. Throws compilation error for other types |
To generate a function with return values, mark it with FunctionKind.Expression
.
To specify a return type declare a property annotated with @Returns
. The return type of the generated Kotlin function
will be derived from the annotated property type.
@LibraryFunction(
name = "custom_glob",
scope = [FunctionScope.Build],
kind = FunctionKind.Expression
)
interface CustomGlob {
@Argument(variadic = true)
val include: ListType<Label?>
@Returns
val returns: ListType<Label>
}
// Kotlin
val builder = BUILD {
val SRCS by custom_glob("src/main/kotlin/**/*.kt")
}
Param | Description |
---|---|
kind |
Optional. Configure if the function in Kotlin DSL has an explicit return type or if it is derived dynamically with type inference |
In some cases the return type of a Kotlin DSL function is inferred based on the call site.
A good example is select
function in Starlark.
// Kotlin
@LibraryFunction(
name = "custom_select",
scope = [FunctionScope.Build],
kind = FunctionKind.Expression
)
interface CustomSelect {
@Argument(required = true, implicit = true)
val select: Map<Key, Value>
@Returns(kind = ReturnKind.Dynamic)
val returns: Any
}
In the example below a custom_select
is being used as a deps
argument, so that its return type is inferred from the
function argument type.
// Kotlin
val builder = BUILD {
android_binary(
name = "app",
deps = custom_select({
":arm_build": [":arm_lib"],
":x86_debug_build": [":x86_dev_lib"],
"//conditions:default": [":generic_lib"],
}),
)
}
# Generated Starlark
android_binary(
name = "app",
deps = custom_select({
":arm_build": [":arm_lib"],
":x86_debug_build": [":x86_dev_lib"],
"//conditions:default": [":generic_lib"],
}),
)
Copyright 2023 Pavlo Stavytskyi.
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.