Skip to content

Morfly/pendant

Repository files navigation

Pendant đź’ 

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.

Materials

Installation

Gradle

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")
}

Maven Central

Overview

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/**"]),
)

Generate Starlark files

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.

Building Starlark files

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 { ... }

Write files to file system

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")

Get file contents as string

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()

Starlark syntax elements

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 assignments

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.

Dynamic variable assignments

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"

List expressions

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.

Dictionary expressions

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" }

Short form

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" },
)

Concatenations

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.

Function calls

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"()

Function call expressions

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.invokefunction 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.

Type-safe API for custom functions

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.

List comprehensions

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]

Nested comprehensions

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
]

Slices

To generate Starlark slices use the following syntax.

// Kotlin
"abc.kt"[0..-3]
# Generated Starlark
"abc.kt"[0:-3]

Load statements

Loading rules/functions

// Kotlin
load("@rules_java//java:defs.bzl", "java_binary")
# Generated Starlark
load("@rules_java//java:defs.bzl", "java_binary")

Loading values

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.

Raw code injection

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

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"],
)

Checkpoints

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,
)

Generate Kotlin DSL for custom Starlark functions

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

Function call expressions

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

Dynamic return type

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"],
    }),
)

License

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.