What if adding Kotlin and Java compilation to your application didn't mean building an entire compilation pipeline yourself?
What if you could add Bytesmith, configure the filesystem once, provide your source files and output destination, and simply compile?
That's the idea behind Bytesmith.
Bytesmith is a Kotlin and Java compiler toolkit designed for JVM and Android applications. It provides a unified API for Kotlin, Java, and mixed-language compilation, while also supporting filesystem abstraction, custom classpaths, boot classpaths, compiler plugins, packaging, and diagnostics.
Configure the environment, provide the source, specify the output, and compile.
The problem
Compiler tooling can become surprisingly difficult when it is tightly coupled to the environment in which it was originally designed to run.
You might need to deal with:
- Kotlin compiler versions
- Kotlin standard libraries
- Java compilation
- Bootclasspath configuration
- Dependency classpaths
- Source discovery
- Output handling
- Android storage
- Storage Access Framework URIs
- Packaging
- Compiler diagnostics
And then there is the question of where those files actually live.
On a desktop JVM, you might have traditional filesystem paths:
/home/user/project/src/Main.kt
On Android, you might be working with application storage or files selected through the Storage Access Framework:
content://...
If your compiler API directly depends on java.io.File, your compilation code becomes coupled to one filesystem model.
Bytesmith takes a different approach.
Adding Bytesmith
The goal is to make compilation something you can plug into an application.
With Gradle:
implementation("io.github.sifisofakude.bytesmith:bytesmith-common:1.0.0")
After adding Bytesmith, configure the filesystem your application wants to use.
For a JVM application:
FileSystems.current = JvmFileSystem()
For Android:
FileSystems.current = AndroidSafFileSystem(context)
Once the filesystem is configured, the rest of the compilation layer can operate through the filesystem abstraction.
The basic compilation example
Bytesmith provides a high-level compilation entry point.
For example:
val result = Main().compile(
JvmFileSystem(),
listOf(
"-d", "build/classes",
"src/main/java",
"src/main/kotlin"
)
)
println(result)
The important part is that you provide the filesystem and compilation arguments.
Bytesmith handles the compiler orchestration.
You don't need to build an entire compilation pipeline yourself.
Compiling Kotlin
Bytesmith also exposes the Kotlin compiler directly when you need more control.
For example:
val result = KotlinCompiler(JvmFileSystem()).compile(
Options(
outputDir = "build/classes",
kotlinSources = kotlinSources,
javaSources = emptyList(),
classpath = emptyList(),
bootClasspath = emptyList(),
warningsAsErrors = false
)
)
This gives your application control over the compilation environment while still allowing Bytesmith to handle the underlying compiler integration.
Compiling Java
Bytesmith isn't just a Kotlin compiler wrapper.
Java compilation is supported through ECJ.
For example:
val result = JavaCompiler(JvmFileSystem()).compile(
Options(
outputDir = "build/classes",
javaSources = javaSources,
classpath = emptyList(),
bootClasspath = emptyList(),
warningsAsErrors = false
)
)
This means an application can use Bytesmith for Java compilation without implementing its own ECJ integration.
Mixed Kotlin and Java projects
Bytesmith can also compile projects containing both Kotlin and Java source files.
For example:
src/
└── main/
├── kotlin/
│ └── Example.kt
└── java/
└── JavaExample.java
You can provide both types of source files to the compilation process:
val sources = listOf(
"src/main/kotlin/Example.kt",
"src/main/java/JavaExample.java"
)
The Kotlin compiler and Java compiler are orchestrated as part of the compilation process.
This is useful for applications that generate source code, developer tools, code editors, custom build systems, or other environments where compilation itself is an application feature.
The filesystem abstraction
This is one of the most important parts of Bytesmith.
A compiler shouldn't necessarily care where a source file physically comes from.
A desktop application might use:
/home/user/project/Main.kt
An Android application might use application storage.
Or the source might come from a user-selected SAF document:
content://...
These are fundamentally different storage mechanisms.
But the compilation operation doesn't need to be.
The application chooses the filesystem implementation:
FileSystems.current = JvmFileSystem()
or:
FileSystems.current = AndroidSafFileSystem(context)
Bytesmith then works through the filesystem abstraction.
The architecture looks roughly like this:
Application
|
v
FileSystems.current
|
+---------+---------+
| |
v v
JvmFileSystem AndroidSafFileSystem
| |
+---------+---------+
|
v
Bytesmith
|
+-------+-------+
| |
v v
Kotlin compiler ECJ
| |
+-------+-------+
|
v
Output
The compiler doesn't have to know whether the underlying storage is a JVM filesystem or SAF.
Android and SAF
This becomes particularly useful on Android.
The Storage Access Framework doesn't behave like a traditional filesystem.
Instead of receiving a normal filesystem path, your application may receive a URI:
content://...
You may then need to work with:
ContentResolver- Document providers
- Document tree URIs
- Persisted permissions
DocumentFileDocumentsContract
Bytesmith's filesystem abstraction allows those details to stay inside the filesystem implementation.
The compilation code doesn't need to become filled with Android-specific storage logic.
You configure the filesystem once.
Then Bytesmith can work through it.
Scoped Storage, SAF, or JVM paths?
From the perspective of the compilation API, it shouldn't fundamentally matter whether your application is using:
JVM filesystem
|
+-- /home/user/project/Main.kt
or:
Android application storage
|
+-- /data/user/0/...
or:
Storage Access Framework
|
+-- content://...
Those are different storage mechanisms.
They don't have to result in completely different compiler implementations.
The filesystem implementation handles the difference.
That's one of the reasons I built the filesystem abstraction separately from the compilation logic.
Android usage
An Android application can configure the appropriate filesystem implementation:
FileSystems.current = AndroidSafFileSystem(context)
After that, the same Bytesmith compilation layer can operate against the configured filesystem.
If SAF resources need to be materialized for a particular compilation operation, the filesystem implementation can handle that as well.
For example:
val local = fs.materialize(uri, "Sources")
and later:
fs.clearMaterialized("Sources")
The important thing is that the compilation layer doesn't need to implement its own SAF handling.
Classpaths
Real compilation usually requires dependencies.
Bytesmith allows you to provide a custom classpath.
For example:
val options = Options(
outputDir = "build/classes",
kotlinSources = kotlinSources,
javaSources = javaSources,
classpath = listOf(
"libs/library.jar",
"libs/another-library.jar"
),
bootClasspath = emptyList(),
warningsAsErrors = false
)
The application decides what libraries should be visible to the compiler.
Bytesmith handles passing those dependencies into the appropriate compiler.
Kotlin standard library versions
This separation is also useful when working with Kotlin's standard library.
The Kotlin compiler and Kotlin standard library are related, but they aren't exactly the same thing.
If your generated source needs APIs from a newer Kotlin standard library, you can provide the appropriate:
kotlin-stdlib.jar
through the compilation classpath.
For example:
val classpath = listOf(
"libs/kotlin-stdlib.jar",
"libs/my-library.jar"
)
This means an update to the standard library doesn't automatically mean Bytesmith itself needs to be updated simply because the library JAR changed.
There is an important limitation.
Providing a newer kotlin-stdlib.jar does not make an older Kotlin compiler understand new Kotlin language features.
If a newer Kotlin release introduces new syntax, compiler functionality, or lowering requirements, the compiler itself needs to support those features.
The goal is not to pretend compiler versions don't matter.
The goal is to avoid coupling things that can evolve independently.
Bootclasspath
Compilation environments sometimes require a bootclasspath.
Bytesmith can detect the appropriate bootclasspath where possible.
If you need explicit control, you can provide one yourself.
This gives you two options.
Simple case
Let Bytesmith determine the environment.
Advanced case
The application supplies the bootclasspath.
You get convenience without losing control.
Compiler plugins
Bytesmith also supports Kotlin compiler plugins.
You can provide plugin JARs and plugin options as part of the compilation environment.
For example:
val options = Options(
pluginClasspath = listOf(
"plugins/my-plugin.jar"
),
pluginOptions = listOf(
"plugin:my.plugin:key=value"
)
)
This makes Bytesmith useful for more than straightforward source compilation.
An application can construct a compilation environment specifically for the code it needs to compile.
Packaging the output
Compilation doesn't necessarily have to end with a directory containing .class files.
Bytesmith can also package compiled output into JAR or ZIP archives.
For example:
build/classes
can be used as a directory output, while an application can also produce:
app.jar
or:
app.zip
This is useful when the compiled result needs to be consumed as an artifact rather than simply left in a classes directory.
Diagnostics
Compilation errors are only useful if your application can actually report them.
Bytesmith exposes compilation results and listener callbacks.
For example:
class Listener : ICompilationListener {
override fun hasErrors() = false
override fun onProblem(problem: CompilationProblem) {
problem.printMessage()
}
override fun onClassCompiled(compiledClass: CompiledClass) {
println(compiledClass.fileName)
}
}
The compilation result can also provide information such as:
println(result.success)
println(result.errorCount)
println(result.warningCount)
println(result.compiledClassCount)
println(result.elapseTimeMillis)
This becomes particularly useful when Bytesmith is embedded inside a UI.
An Android code editor, for example, could take compiler diagnostics and display them directly to the user rather than dumping compiler output to a terminal.
Command-line usage
Bytesmith isn't limited to an embedded API.
It also provides a command-line interface.
A compilation can look like:
bytesmith -cp libs/* -bc jmods -d build/classes src/main/java src/main/kotlin
The CLI exposes options for classpaths, boot classpaths, module paths, source paths, compiler plugins, plugin options, output destinations, and warnings-as-errors.
This means the same compilation capabilities can be used programmatically or from the command line.
Why not just use Gradle?
Gradle is excellent when you're building a conventional project.
But that's not necessarily the problem Bytesmith is trying to solve.
Imagine an application where the application itself needs to compile source code.
For example:
- An Android IDE
- A code editor
- A developer tool
- A code generator
- A scripting environment
- A custom build tool
- An educational programming environment
- An application that generates Kotlin or Java code dynamically
In these situations, starting a complete Gradle build just to compile a set of source files can be excessive.
You may simply want:
Source files
|
v
Compile
|
v
Classes / JAR
Bytesmith provides that compilation layer.
The architecture
The architecture can be summarized as four responsibilities.
1. Your application
Decides what it wants to compile and where the result should go.
2. The filesystem abstraction
Decides how files are actually accessed.
JvmFileSystem
AndroidSafFileSystem
3. Bytesmith
Orchestrates compilation, classpaths, compiler configuration, diagnostics, and packaging.
4. Language compilers
Kotlin is compiled through the Kotlin compiler.
Java is compiled through ECJ.
This separation is what makes the whole thing composable.
The result
For a simple use case, the workflow is essentially:
Configure filesystem
|
v
Provide sources
|
v
Compile
|
v
Get result
For advanced applications, you can progressively add:
Classpath
Bootclasspath
Compiler plugins
Plugin options
Diagnostics
Packaging
without having to replace the underlying compilation architecture.
That's the balance Bytesmith is aiming for:
Simple when you need simple. Configurable when you need control.
Final thoughts
I built Bytesmith around a fairly simple idea:
Compilation should be a capability an application can plug in, not an entire infrastructure the application has to reinvent.
It can compile Kotlin.
It can compile Java.
It can handle mixed Kotlin/Java projects.
It can work with custom classpaths and boot classpaths.
It supports compiler plugins and packaging.
And, perhaps most importantly for Android, it doesn't assume that every file is a traditional JVM filesystem path.
The application chooses the filesystem implementation.
Bytesmith works through that abstraction.
So whether you're compiling files from a desktop filesystem, Android application storage, or resources exposed through the Storage Access Framework, the compilation layer can remain the same.
Configure the filesystem.
Give Bytesmith the source.
Configure dependencies when necessary.
Choose the output.
Compile.
That's Bytesmith.
Top comments (1)
Great one !