ktrs
Kotlin formatting and linting in Rust, without starting a JVM.
ktrs is a native replacement for ktfmt and ktlint. It produces the same output, it is 13-190x faster, and it ships as small native binaries with no runtime.
- ⚡ Fast. Under 10 ms per file in an editor or pre-commit hook, against roughly a second of JVM startup. On whole projects it uses every core and is still at least 13x faster.
- 🎯 Identical output. Byte-identical to ktfmt 0.64 on 6,121 of 6,123 real-world files (both
tools reject the other two). Lint violations and
--formatoutput match ktlint 2.0 in all three code styles, and ktlint 1.8 in a compatibility mode. - 🔌 Drop-in. The
ktfmtandktlintbinaries accept the originals' flags, messages and exit codes, so existing scripts, hooks and CI keep working. - 🧩 Fits your setup. Integrations for GitHub Actions, pre-commit, Spotless, ktfmt-gradle and ktlint-gradle drop-in plugins, and Neovim, Helix, Zed, Emacs and VS Code.
- 🌳 Built on a faithful parser. ktrs includes a lossless Kotlin parser whose tree matches the Kotlin compiler's PSI node for node.
Try the formatter in your browser in the playground, which runs it as WebAssembly.
Installation
| && &&
One install puts three binaries on your PATH: ktrs, ktfmt and ktlint. On Windows, the
install script also works under Git Bash, or download a zip from
Releases. The Docker image takes the binary name as its first argument,
works in /src, and has no Java, so ktlint -R with a rule set other than compose-rules doesn't run there; on
Linux, add --user "$(id -u):$(id -g)" to keep fixed files owned by you.
Usage
Drop-in for ktfmt and ktlint
The ktfmt and ktlint binaries accept the original command lines exactly, so anything that runs
the jars can run them instead:
ktfmtsupports all of ktfmt's flags, plus@argfile,-for stdin and--enable-editorconfig.ktlintsupports!negation in patterns,-F,--stdin,--patterns-from-stdin,--baseline,--editorconfig, every built-in reporter, and the git hook subcommands.- ktlint implements 2.0.0-ALPHA-4, with its engine and all 105 standard rules.
ktlint 1.8. Most teams still run ktlint 1.x, and 2.0 changed rule results, autocorrect order,
exit codes and baseline matching (research/22). To get 1.8.0
behaviour, add this to .editorconfig (the ktlint jars ignore it), or pass --ktlint-version=1.8:
[*.{kt,kts}]
ktrs_ktlint_version = 1.8
This mode ports 1.8's rule differences, its rule-by-rule autocorrect order and its CLI. On 21,000 files it matches the 1.8.0 jar except for a few KDoc whitespace rows, where 1.8's older Kotlin lexer splits trailing spaces differently (research/26).
Custom rule sets. -R jars are supported, in ktlint and ktrs lint.
compose-rules 0.6.7, by far the most used rule set,
runs natively with output identical to the jar (the -all.jar and the Maven artifacts). Any other
rule set or reporter jar hands the whole run to the real ktlint jar (downloaded once and checked by
SHA-256; needs Java), so it works at JVM speed (research/27).
Limits.
- kotlinter runs ktlint inside the JVM and doesn't use these binaries.
- The
ktfmtbinary accepts Kotlin 2.4 syntax (e.g.companion { }blocks) that ktfmt 0.64, built on Kotlin 2.3, rejects.
Integrations
Migrating
ktrs migrate switches a build's ktfmt-gradle, ktlint-gradle, ktlint-maven-plugin and Spotless
setup to the ktrs drop-ins described below, editing only ids, coordinates and versions in place.
Setups it can't rewrite, such as kotlinter, get a note: saying what to change by hand.
GitHub Actions
- uses: Hexay/ktrs@v0.5.0 # Linux, macOS and Windows
- run: ktrs fmt --check --style kotlinlang
- run: ktrs lint
pre-commit
No Rust needed: on first run, the hook downloads the release binaries for its rev.
- repo: https://github.com/Hexay/ktrs
rev: v0.5.0
hooks:
- id: ktrs-fmt # also: ktrs-fmt-check, ktfmt (with ktfmt's flags in `args`)
args:
- id: ktrs-lint # also: ktrs-lint-format, ktlint (with ktlint's flags in `args`)
Editors
ktrs lsp is a language server for ktlint diagnostics, quick fixes and ktfmt or ktlint formatting.
It runs next to your Kotlin language server and takes its setup from the Gradle or Maven build. In VS
Code, install the hexay.ktrs extension, which bundles it. Editor
plugins that already run ktlint or ktfmt (conform.nvim, nvim-lint, none-ls, ALE, apheleia,
flycheck-kotlin, Helix, Zed, VS Code's mskelton.ktlint, Block's IntelliJ Kotlin Formatter) work
unchanged with the drop-in binaries; their exact invocations are diffed against the jars. Configs for
both: docs/editors.md.
Gradle
ktfmt-gradle drop-in. The io.github.hexay.ktrs plugin replaces
ktfmt-gradle 0.27.0. Change only the plugin id. The
ktfmt { } block, the ktfmtCheck/ktfmtFormat* tasks, --include-only and the
com.ncorti.ktfmt.gradle.* imports keep working.
plugins {
id("io.github.hexay.ktrs") version "0.5.0" // was: id("com.ncorti.ktfmt.gradle") version "0.27.0"
}
ktlint-gradle drop-in. The io.github.hexay.ktrs.ktlint plugin replaces
ktlint-gradle 14.2.0 the same way: the ktlint { }
block, ktlintCheck/ktlintFormat and the per-source-set, baseline and git hook tasks,
ktlintRuleset(...) and the org.jlleitschuh.gradle.ktlint.* types keep working. Console output,
reports and formatted files match the original with ktlint 1.8.0
(research/29).
plugins {
id("io.github.hexay.ktrs.ktlint") version "0.5.0" // was: id("org.jlleitschuh.gradle.ktlint") version "14.2.0"
}
version defaults to "1.8.0"; "2.0.0-ALPHA-4" selects 2.0, and other versions fail the build.
compose-rules runs natively; other rule sets run the task through the real ktlint jar.
Spotless. KtrsStep replaces ktfmt() and KtrsKtlintStep replaces ktlint() (Spotless 7+):
buildscript {
repositories { mavenCentral() }
dependencies { classpath("io.github.hexay:ktrs:0.5.0") }
}
spotless {
kotlin {
addStep(io.github.hexay.ktrs.spotless.KtrsStep.create(io.github.hexay.ktrs.KtrsOptions.kotlinlang()))
// or, instead of ktlint("1.8.0").editorConfigOverride(...).customRuleSets(...):
addStep(io.github.hexay.ktrs.spotless.KtrsKtlintStep.create(io.github.hexay.ktrs.KtlintOptions.defaults()
.withEditorConfigPath(rootProject.file(".editorconfig"))
.withEditorConfigOverride(mapOf("ktlint_code_style" to "ktlint_official"))))
}
}
KtrsKtlintStep gives the same results as Spotless's ktlint("1.8.0") step
(research/28). withCustomRuleSets(files) takes jar files;
only compose-rules is supported there, other rule sets need Spotless's ktlint().
Until the plugin is on the Gradle Plugin Portal, add the repository in settings.gradle.kts:
pluginManagement {
repositories {
gradlePluginPortal()
maven("https://hexay.github.io/ktrs/maven")
}
}
The io.github.hexay:ktrs jar has no dependencies. It bundles the native binaries for Linux, macOS
and Windows (x86-64 and ARM) and keeps long-lived ktrs serve processes, so a build starts the
binary once, not once per file.
KtrsOptionsmirrors ktfmt's options: start frommeta(),google()orkotlinlang(), then chainwithMaxWidth,withBlockIndent,withContinuationIndent,withRemoveUnusedImports,withTrailingCommasandwithEditorConfig(true).- From other JVM code,
Ktrs.create()returns a thread-safe formatter:ktrs.format(code, KtrsOptions.google()), orktrs.ktlint(code, KtlintOptions.defaults(), path)for ktlint's formatted code and remaining violations. - The plugin accepts
useClassloaderIsolation,processIsolationJvmArgsandktfmtClasspathbut ignores them.debuggingPrintOpsAfterFormattingonly logs a warning. - To use a different binary from the bundled one, set the Gradle property
ktrs.executable. - Without the jar, Spotless's generic step runs the binary once per file:
nativeCmd("ktfmt", "/path/to/ktfmt", listOf("--kotlinlang-style", "-")).
Maven
ktlint-maven-plugin drop-in. io.github.hexay:ktrs-ktlint-maven-plugin replaces gantsign's
ktlint-maven-plugin 3.7.1. Change only the
coordinates: the goals (mvn ktlint:check, ktlint:format, the ktlint site report), parameters,
ktlint.* properties, <reporters> and rule sets in the plugin's <dependencies> keep working, and
console output, reports and formatted files match the original
(research/31).
io.github.hexay <!-- was: com.github.gantsign.maven -->
ktrs-ktlint-maven-plugin <!-- was: ktlint-maven-plugin -->
0.5.0
check
<ktlintVersion> (property ktrs.ktlintVersion) defaults to 1.8.0; 2.0.0-ALPHA-4 selects 2.0.
Spotless. Add implementation= to the existing <ktfmt> or <ktlint> element and the jar as a
plugin dependency; the other options stay as they are (Maven 3.9+, Java 17+):
com.diffplug.spotless
spotless-maven-plugin
KOTLINLANG
<!-- or <ktlint implementation="io.github.hexay.ktrs.spotless.maven.KtrsKtlint">…</ktlint> -->
io.github.hexayktrs0.5.0
Output is identical to stock <ktfmt> 0.64 and <ktlint> 1.8.0 (tools/spotless-maven/parity.sh).
<version> must be left out or match (ktfmt 0.64; ktlint 1.8.0 or 2.0.0-ALPHA-4).
Performance
Each tool is run from the command line the way users run it: same flags, same files, identical output. Timings are wall time, the median of 5 runs after a warm-up, measured with hyperfine on Linux (Xeon E-2136, 10 CPUs, JDK 21).
Formatting, the ktfmt binary against the ktfmt 0.64 jar:
| Scenario | ktrs | ktfmt 0.64 | Speedup |
|---|---|---|---|
| Editor: one 8 KB file on stdin | <5 ms | 791 ms | >150x |
| Pre-commit: 10 changed files | 16 ms | 743 ms | 47x |
| CI check on okhttp (617 files) | 183 ms | 3.87 s | 21x |
| Format okhttp in place | 176 ms | 3.30 s | 19x |
| Format okhttp in place, 1 core | 505 ms | 12.46 s | 25x |
| Format 7 projects (6,123 files, 31 MB) | 813 ms | 10.91 s | 13x |
Linting, the ktlint binary against the ktlint 2.0.0-ALPHA-4 jar:
| Scenario | ktrs | ktlint 2.0 | Speedup |
|---|---|---|---|
| Editor: one 8 KB file on stdin | <10 ms | 1.06 s | >100x |
| Lint one file | <10 ms | 845 ms | >80x |
| Lint okhttp (617 files) | 344 ms | 11.56 s | 34x |
Autocorrect okhttp (-F) |
640 ms | 123.0 s | 192x |
| Lint okhttp, 1 core | 1.37 s · 49 MB | 39.36 s · 366 MB | 29x |
| Lint 7 projects (6,123 files) | 1.72 s · 268 MB | 49.73 s · 553 MB | 29x |
Autocorrect 7 projects (-F) |
4.00 s | 338.9 s | 85x |
The ktlint 1.8.0 jar is 10-30% faster than 2.0 on these runs. ktrs's 1.8 mode visits the tree once per rule, as 1.8 does, and is up to 2x slower than its 2.0 mode.
JVM startup dominates small runs. On large runs ktrs is still several times faster per core, and it uses every core. The binary is a few MB with no runtime, compared with a 71 MB jar plus a JRE.
- Corpus. The 7 projects are okhttp, kotlinx.coroutines, nowinandroid, ktlint, ktfmt, Exposed
and ktor, pinned in
tools/bench/REVISIONS. - 1 core. Both processes are pinned to one CPU from launch. The JVM then sizes its GC and JIT threads for one CPU, as it would in a 1-CPU container, and its JIT competes with the work.
- Identical output. ktfmt rejects 2 of the 6,123 files, Exposed's
{{packageName}}code-generator templates, and ktrs rejects them with the same error. - Reproduce.
tools/bench/public.sh(Linux or macOS; needs hyperfine, Java and acargo build --profile dist) fetches the corpus, downloads the jars and checks their SHA-256, and runs every scenario above;--only quickruns a subset. Thebenchworkflow runs it on a GitHub runner for each release tag or on demand. Method, noise and the ktlint 1.8 figures are in research/23.
Spotless. spotlessApply on okhttp's 573 files gives identical output. The figures are the
formatter's share, after subtracting a Spotless run that only trims whitespace:
| Spotless step | ktrs (KtrsStep) |
ktfmt 0.64 (ktfmt()) |
|---|---|---|
| Fresh Gradle daemon, as in CI | ~4 s | ~52 s |
| Warm daemon, repeated runs | ~2 s | ~6 s |
How correctness is checked
Parity with the original tools is the spec. The ported test suites run in CI, and the corpus diffs
(cargo corpus-diff, cargo fmt-diff, cargo lint-diff) compare against the real tools on ~6,000
files:
-
Parser. The tree must match the Kotlin compiler's PSI (
DebugUtil.psiToString) on the compiler's own test fixtures and on every corpus file. -
Formatter. ktfmt's test suite is ported. The output is also diffed byte for byte against the ktfmt jar on the corpus in the meta, google and kotlinlang styles.
-
Linter. ktlint's rule tests are ported. Violations and
--formatoutput are diffed against the ktlint jar on the corpus in thektlint_official,intellij_ideaandandroid_studiocode styles, with and without experimental rules. -
CLIs. The
ktfmtandktlintbinaries are compared with the jars on stdout, stderr, exit code and written files across a scenario suite. -
Held-out corpus. To check that the corpus work didn't overfit, the CLIs also run against a second corpus that never drove a fix. It has 15,287 files from 20 other projects, pinned in
tools/holdout/REVISIONS. In the first run:- All 2.2M lint violations matched ktlint in all three styles.
- ktfmt output matched on every file in all three styles.
--formatoutput differed on 3 files, from one rule. That bug is now fixed.
Details are in research/21;
tools/holdout/run.shreruns it. The ktlint 1.8 mode and compose-rules are checked against their jars on both corpora too. -
Fuzzing.
fuzz/has cargo-fuzz targets for the parser, ktfmt and ktlint, andtools/fuzz/diff.shruns mutated real-world files through the binaries and the jars and reports any difference. The first runs found 4 bugs, now fixed, including exponential parser memory on deeply nested generic-looking input (research/24). -
Upstream releases. A weekly workflow opens an issue when ktfmt, ktlint, compose-rules or Kotlin publishes a release newer than the pinned version.
Maintenance
ktrs is maintained by @Hexay. It tracks ktfmt 0.64, ktlint 2.0.0-ALPHA-4 (plus 1.8.0 in compatibility mode), compose-rules 0.6.7 and the Kotlin 2.4.20 parser. New upstream releases are ported and re-checked against the parity gates before a ktrs release. If ktrs output ever differs from ktfmt or ktlint on your code, that's a bug: please open an issue with the file, or a snippet that reproduces it, and the command line you used.
Contributing
CLAUDE.md lists the crate layout and every parity gate. Design notes are in
research/.
License
Dual-licensed under MIT or Apache-2.0, at your option. ktrs contains code and test data ported from the Kotlin compiler, the IntelliJ Platform, ktfmt, google-java-format, ec4j (Apache-2.0), ktlint and ktfmt-gradle (MIT). See NOTICE.