[JEWEL-1335] Gradle Scripts Migration to Bazel (Part 2)

closes https://github.com/JetBrains/intellij-community/pull/3576

(cherry picked from commit a6cc31d181e82f56d39d28a2ebc81d34ef727187)


(cherry picked from commit ad2197f4c189c5b798cff3fcfad29cce35f7b13a)

IJ-MR-220560

GitOrigin-RevId: b0a0ea6d63514c38c8752b7c3f4cb5e82d6eec0e
This commit is contained in:
Daniel Bertoldi
2026-09-07 22:29:09 +00:00
committed by intellij-monorepo-bot
parent 1c5189141b
commit df89c4d40f
5 changed files with 592 additions and 32 deletions
@@ -0,0 +1,58 @@
load("@rules_java//java:defs.bzl", "java_binary")
load("@rules_kotlin//kotlin:jvm.bzl", "kt_jvm_import", "kt_jvm_library")
kt_jvm_import(
name = "kotlinpoet",
jar = "@jewel_deps_kotlinpoet//file",
visibility = ["//visibility:private"],
)
kt_jvm_import(
name = "ktfmt",
jar = "@jewel_deps_ktfmt//file",
visibility = ["//visibility:private"],
)
kt_jvm_library(
name = "jewel-bazel-scripts-utils",
srcs = ["src/main/kotlin/org/jetbrains/jewel/scripts/bazel/JewelBazelScriptsUtils.kt"],
deps = ["@lib//:kotlinx-coroutines-core"],
)
kt_jvm_library(
name = "ktfmt-runner-lib",
srcs = ["src/main/kotlin/org/jetbrains/jewel/scripts/bazel/KtfmtRunner.kt"],
deps = [
":jewel-bazel-scripts-utils",
":ktfmt",
],
)
kt_jvm_library(
name = "jewel-version-updater",
srcs = ["src/main/kotlin/org/jetbrains/jewel/scripts/bazel/JewelVersionUpdater.kt"],
deps = [
":jewel-bazel-scripts-utils",
"@lib//:kotlinx-coroutines-core",
],
)
java_binary(
name = "ktfmtFormat",
args = ["format"],
main_class = "org.jetbrains.jewel.scripts.bazel.KtfmtRunnerKt",
runtime_deps = [":ktfmt-runner-lib"],
)
java_binary(
name = "ktfmtCheck",
args = ["check"],
main_class = "org.jetbrains.jewel.scripts.bazel.KtfmtRunnerKt",
runtime_deps = [":ktfmt-runner-lib"],
)
java_binary(
name = "jewelVersionUpdater",
main_class = "org.jetbrains.jewel.scripts.bazel.JewelVersionUpdaterKt",
runtime_deps = [":jewel-version-updater"],
)
@@ -0,0 +1,342 @@
package org.jetbrains.jewel.scripts.bazel
import java.io.File
import java.util.concurrent.CompletableFuture
import java.util.concurrent.TimeUnit
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.intellij.lang.annotations.Language
private const val CONTROL_CODE = '\u001b'
private const val CODE_SUCCESS = "$CONTROL_CODE[0;32m"
private const val CODE_ERR = "$CONTROL_CODE[0;31m"
private const val CODE_WARN = "$CONTROL_CODE[0;33m"
private const val CODE_BOLD = "$CONTROL_CODE[1m"
private const val CODE_CLEAR = "$CONTROL_CODE[0m"
/**
* Gets the argument at [index], or throws if it's missing.
*
* @param index The index of the argument to retrieve.
* @return The argument at [index].
*/
fun Array<String>.getOrThrow(index: Int) = this.getOrNull(index) ?: error("Index $index does not have any arguments")
/**
* Gets the Bazel workspace root directory.
*
* @return The workspace directory, read from the `BUILD_WORKSPACE_DIRECTORY` environment variable. Throws if that
* variable isn't set or doesn't point at a valid directory — this only works when run via `bazel run`.
*/
fun getBuildWorkspaceDirectory(): File =
File(System.getenv("BUILD_WORKSPACE_DIRECTORY")).takeIf { it.isDirectory }
?: error("BUILD_WORKSPACE_DIRECTORY is not set or is not a valid directory. Run via `bazel run`.".asError())
/**
* Gets the Jewel subproject's root directory.
*
* @return The `platform/jewel` directory under the Bazel workspace root, or `null` if it doesn't exist.
*/
fun getJewelRoot(): File? = File(getBuildWorkspaceDirectory(), "platform/jewel").takeIf { it.isDirectory }
/**
* Gets the runfiles directory for the current `bazel run` invocation.
*
* @return The runfiles directory path, read from the `RUNFILES_DIR` or `JAVA_RUNFILES` environment variable. Throws if
* neither is set — this only works when run via `bazel run`.
*/
fun getRunfilesDir() =
System.getenv("RUNFILES_DIR")
?: System.getenv("JAVA_RUNFILES")
?: error("RUNFILES_DIR is not set. Run via `bazel run`.")
val isWindows = "windows" in System.getProperty("os.name").lowercase()
private val stylingSupported = doesTerminalSupportStyling()
/**
* Detects if the current terminal supports styling (colors, bold, etc.).
*
* @return `true` if a compatible terminal is detected, `false` otherwise.
*/
private fun doesTerminalSupportStyling(): Boolean =
System.getenv("TERM")?.contains("color") == true || System.getenv("COLORTERM") == "truecolor"
fun red(text: String) = "\u001B[31m$text\u001B[0m"
/**
* Formats a string as a success (green) text with ANSI escape codes if the terminal supports it.
*
* @return The formatted text, or the text itself if the terminal does not support styling.
*/
fun String.asSuccess(): String = if (stylingSupported) "$CODE_SUCCESS$this$CODE_CLEAR" else this
/**
* Formats a string as a warning message with ANSI escape codes.
*
* @return The formatted text, or the text itself if the terminal does not support styling.
*/
fun String.asWarning() = if (stylingSupported) "$CODE_WARN$this$CODE_CLEAR" else this
/**
* Formats a string as an error message with ANSI escape codes if the terminal supports it.
*
* @return The formatted text, or the text itself if the terminal does not support styling.
*/
fun String.asError() = if (stylingSupported) "$CODE_ERR$this$CODE_CLEAR" else this
/**
* Formats a string as a bold text with ANSI escape codes if the terminal supports it.
*
* @return The formatted text, or the text itself if the terminal does not support styling.
*/
fun String.asBold(): String = if (stylingSupported) "$CODE_BOLD$this$CODE_CLEAR" else this
/**
* Prints a success message to the standard output stream with success (green) formatting.
*
* @param message The success message to print.
*/
fun printlnSuccess(message: String) {
println(message.asSuccess())
}
/**
* Prints a warning message to the standard error stream with warning formatting.
*
* @param message The warning message to print.
*/
fun printlnWarn(message: String) {
System.err.println(message.asWarning())
}
/**
* Prints an error message to the standard error stream with error formatting.
*
* @param message The error message to print.
*/
fun printlnErr(message: String) {
System.err.println(message.asError())
}
/**
* Checks whether the given directory is (part of) a git repository.
*
* @return true if it is, false otherwise.
*/
suspend fun isDirectoryGitRepo(directory: File, runner: CommandRunner = DefaultCommandRunner): Boolean =
runner("git rev-parse --is-inside-work-tree", directory, exitOnError = false).isSuccess
/**
* Checks whether the given branch exists in the current git repository.
*
* @param branch The name of the branch to check.
* @return true if the branch exists, false otherwise.
*/
suspend fun branchExists(branch: String, directory: File, runner: CommandRunner = DefaultCommandRunner): Boolean =
runner("git rev-parse --verify $branch", directory).isSuccess
/**
* Gets the date of the latest release from the "RELEASE NOTES.md" file.
*
* @return The latest release date in "yyyy-mm-dd" format if found, otherwise `null`.
*/
fun getLatestReleaseDate(baseDir: File = File(".").canonicalFile): String? {
val releaseNotesFile = File(baseDir, "RELEASE NOTES.md")
if (!releaseNotesFile.exists()) {
printlnWarn(
"⚠️ Release notes file not found at '${releaseNotesFile.absolutePath}', can't determine start date."
)
return null
}
val releaseHeaderRegex = """## v\d+\.\d+ \((....-..-..)\)""".toRegex()
releaseNotesFile.useLines { lines ->
for (line in lines) {
val match = releaseHeaderRegex.find(line)
if (match != null) {
return match.groupValues[1]
}
}
}
printlnWarn("⚠️ Could not find any release date in RELEASE NOTES.md.")
return null
}
interface CommandRunner {
suspend operator fun invoke(
@Language("shell") command: String,
workingDir: File?,
timeoutAmount: Duration = 60.seconds,
exitOnError: Boolean = true,
streamOutput: Boolean = false,
): CmdResult
}
object DefaultCommandRunner : CommandRunner {
override suspend fun invoke(
command: String,
workingDir: File?,
timeoutAmount: Duration,
exitOnError: Boolean,
streamOutput: Boolean,
): CmdResult = runCommand(command, workingDir, timeoutAmount, exitOnError, streamOutput)
}
/**
* Runs a shell command and captures its output and exit code.
*
* @param command The command to run.
* @param workingDir The working directory for the command. Can be `null` to use the current directory.
* @param timeoutAmount The maximum duration to wait for the command to complete. Defaults to 60 seconds.
* @param exitOnError If `true`, the process will exit if the command returns a non-zero exit code. Defaults to `true`.
* @return A [CmdResult] object containing the output and exit code of the command.
*/
private suspend fun runCommand(
@Language("shell") command: String,
workingDir: File?,
timeoutAmount: Duration = 60.seconds,
exitOnError: Boolean = true,
streamOutput: Boolean = false,
): CmdResult =
withContext(Dispatchers.IO) {
val process =
ProcessBuilder(command.split(" "))
.directory(workingDir)
.redirectErrorStream(true)
.redirectOutput(if (streamOutput) ProcessBuilder.Redirect.INHERIT else ProcessBuilder.Redirect.PIPE)
.redirectError(if (streamOutput) ProcessBuilder.Redirect.INHERIT else ProcessBuilder.Redirect.PIPE)
.start()
val outputFuture =
if (!streamOutput) {
CompletableFuture.supplyAsync { process.inputStream.bufferedReader().readText() }
} else null
val exitCode = process.waitFor()
val output = outputFuture?.get(timeoutAmount.inWholeSeconds, TimeUnit.SECONDS) ?: ""
if (exitCode == 0) {
CmdResult.Success(output)
} else {
if (exitOnError) {
error(
buildString {
appendLine()
append("Command '$command' failed with exit code $exitCode".asError())
if (output.isNotBlank()) {
appendLine(":".asError())
appendLine(output.asError())
} else {
appendLine()
}
}
)
}
CmdResult.Failure(output)
}
}
// Similar to Kotlin's Result, but always carries output
sealed interface CmdResult {
val output: String
val isSuccess: Boolean
get() = this is Success
val isFailure: Boolean
get() = this is Failure
/** Gets the output of the command if it was successful, otherwise throws an exception. */
fun getOrThrow(): String =
if (isSuccess) {
output.trim()
} else {
throw UnsupportedOperationException("Command failed with output $output")
}
/**
* Represents a successful command execution.
*
* @param output The standard output of the command.
*/
data class Success(override val output: String) : CmdResult
/**
* Represents a failed command execution.
*
* @param output The standard error of the command.
*/
data class Failure(override val output: String) : CmdResult
}
suspend fun getCurrentBranchName(directory: File, runner: CommandRunner = DefaultCommandRunner): String =
runner("git rev-parse --abbrev-ref HEAD", directory, exitOnError = true).output.trim()
/**
* Gets the current terminal width by executing `tput cols`, `stty size`, or checking COLUMNS, or using a fallback
* value.
*
* @return The terminal width in columns.
*/
fun getTerminalWidth(): Int {
try {
val tputCols = runBlocking { runCommand(command = "tput cols", workingDir = null, exitOnError = false) }
if (tputCols.isSuccess) return tputCols.output.trim().toInt()
val sttySize = runBlocking { runCommand(command = "stty size", workingDir = null, exitOnError = false) }
if (sttySize.isSuccess) return sttySize.output.trim().split(" ").last().toInt()
return System.getenv("COLUMNS")?.toIntOrNull() ?: 80
} catch (_: Exception) {
return 80
}
}
/**
* Detects if the current terminal likely supports OSC 8 hyperlinks by checking for known environment variables.
*
* @return `true` if a compatible terminal is detected, `false` otherwise.
*/
private fun doesTerminalSupportHyperlinks(): Boolean {
// Check for iTerm, VSCode, Hyper, WezTerm, etc.
val termProgram = System.getenv("TERM_PROGRAM")
if (termProgram != null) {
return when (termProgram) {
"iTerm.app",
"vscode",
"WezTerm",
"Hyper" -> true
else -> false
}
}
// Check for VTE-based terminals (GNOME Terminal, Tilix)
// Support was added in version 0.50 -> 5000
val vteVersion = System.getenv("VTE_VERSION")?.toIntOrNull()
if (vteVersion != null && vteVersion >= 5000) {
return true
}
// Check for IntelliJ's built-in terminal
if (System.getenv("TERMINAL_EMULATOR") == "JetBrains-JediTerm") {
return true
}
// Fallback if no known terminal is detected
return false
}
/**
* Formats a string as a hyperlink using OSC 8 escape codes if the terminal supports it.
*
* @param url The URL for the hyperlink.
* @return The formatted string with the hyperlink, or the original string if hyperlinks are not supported.
*/
fun String.asLink(url: String): String {
if (!doesTerminalSupportHyperlinks()) return this
val bell = '\u0007' // Bell character (acts as separator)
return "$CONTROL_CODE]8;;$url$bell$this$CONTROL_CODE]8;;$bell"
}
@@ -0,0 +1,83 @@
package org.jetbrains.jewel.scripts.bazel
import java.io.File
import java.util.Properties
import kotlin.system.exitProcess
// Run it like: bazel run //platform/jewel/build-scripts/bazel:jewelVersionUpdater
fun main(args: Array<String>) {
print("⏳ Locating Jewel folder...")
val jewelDir = getJewelRoot()
if (jewelDir == null || !jewelDir.isDirectory) {
printlnErr(
"Could not find the Jewel folder. Please make sure you're running the script from somewhere inside it."
)
exitProcess(1)
}
println(" DONE: ${jewelDir.absolutePath}")
print("🔍 Looking up Jewel API version...")
val apiVersion = loadJewelVersionFromProperties(jewelDir)
println(" DONE: $apiVersion")
print("✍️ Writing updated Jewel API version...")
val outFile = updateJewelApiVersion(jewelDir, apiVersion)
print(" DONE\n Generated ")
val relativePath = outFile.relativeTo(getBuildWorkspaceDirectory()).path
println(relativePath.asLink("file://${outFile.absolutePath}"))
}
// =============================== HELPERS =============================== //
private fun loadJewelVersionFromProperties(jewelDir: File): String {
val propertiesFile = File(jewelDir, "gradle.properties")
if (!propertiesFile.isFile) {
printlnErr("Could not find the gradle.properties file.")
exitProcess(1)
}
val properties = Properties().apply { propertiesFile.inputStream().use { load(it) } }
val version = properties.getProperty("jewel.release.version")?.trim()
if (version.isNullOrBlank()) {
printlnErr("Could not find the Jewel API version in gradle.properties (jewel.release.version)")
exitProcess(1)
}
return version
}
private fun updateJewelApiVersion(jewelDir: File, apiVersion: String): File {
val outFile =
File(jewelDir, "foundation/src/main/generated-kotlin/org/jetbrains/jewel/foundation/JewelApiVersion.kt")
outFile.parentFile.mkdirs()
outFile.writeText(jewelApiVersionTemplate.replace(JEWEL_API_VERSION_PLACEHOLDER, apiVersion))
return outFile
}
private const val JEWEL_API_VERSION_PLACEHOLDER = "%%JEWEL_VERSION%%"
private val jewelApiVersionTemplate =
"""
|// ATTENTION: this file is auto-generated. DO NOT EDIT MANUALLY!
|// Use the jewel-version-updater script instead.
|
|package org.jetbrains.jewel.foundation
|
|/** The Jewel API version for this build, expressed as a string. E.g.: "0.30.0" */
|public val JewelBuild.apiVersionString: String
| get() = "%%JEWEL_VERSION%%"
|
"""
.trimMargin()
@@ -0,0 +1,77 @@
package org.jetbrains.jewel.scripts.bazel
import com.facebook.ktfmt.format.Formatter
import com.facebook.ktfmt.format.FormattingOptions
import java.io.File
import kotlin.system.exitProcess
// Run it like: bazel run //platform/jewel/build-scripts/bazel:ktfmtCheck or ktfmtFormat
fun main(args: Array<String>) {
val parameter: String = args.getOrThrow(0)
val jewelRoot = getJewelRoot()
val filesWithWrongFormat: MutableList<String> = mutableListOf()
val formattingOptions =
FormattingOptions(
maxWidth = 120,
blockIndent = 4,
continuationIndent = 4,
manageTrailingCommas = true,
removeUnusedImports = true,
)
when (parameter) {
"format" -> {
jewelRoot
?.walkTopDownValidDirectories()
?.filter { it.isKotlinFile() }
?.forEach { file ->
val formattedFile = Formatter.format(formattingOptions, file.readText())
file.writeText(formattedFile)
}
println("\n${"SUCCESS:".asSuccess()} Files were formatted.")
}
"check" -> {
jewelRoot
?.walkTopDownValidDirectories()
?.filter { it.isKotlinFile() }
?.forEach { file ->
val fileText = file.readText()
val formattedFile = Formatter.format(formattingOptions, fileText)
if (formattedFile != fileText) {
filesWithWrongFormat.add(file.path)
}
}
if (filesWithWrongFormat.isNotEmpty()) {
println("\n${red("ERROR: ")} Some files are not properly formatted. Here's the list:")
filesWithWrongFormat.forEach { filePath -> println(" - $filePath") }
println(
"\nPlease run `bazel run //platform/jewel/build-scripts/bazel:ktfmtFormat` in the terminal or run " +
"the `ktfmtFormat` target in platform/jewel/build-scripts/BUILD.bazel file."
)
exitProcess(1)
} else {
println("\n${"SUCCESS:".asSuccess()} All files are correctly formatted.")
}
}
else -> {
error("Unknown parameter '$parameter'. Valid values: format, check")
}
}
}
private fun File.walkTopDownValidDirectories() =
this.walkTopDown().onEnter { dir ->
dir.name != "build" &&
dir.name != "generated" &&
dir.name != "buildSrc" // TODO: remove this check once we get rid of Gradle.
}
private fun File.isKotlinFile() = this.isFile && this.name.substringAfterLast(".") == "kt"
@@ -58,14 +58,14 @@ public class TitleBarStyle(
override fun toString(): String {
return "TitleBarStyle(" +
"colors=$colors, " +
"metrics=$metrics, " +
"icons=$icons, " +
"dropdownStyle=$dropdownStyle, " +
"iconButtonStyle=$iconButtonStyle, " +
"paneButtonStyle=$paneButtonStyle, " +
"paneCloseButtonStyle=$paneCloseButtonStyle" +
")"
"colors=$colors, " +
"metrics=$metrics, " +
"icons=$icons, " +
"dropdownStyle=$dropdownStyle, " +
"iconButtonStyle=$iconButtonStyle, " +
"paneButtonStyle=$paneButtonStyle, " +
"paneCloseButtonStyle=$paneCloseButtonStyle" +
")"
}
public companion object
@@ -145,20 +145,20 @@ public class TitleBarColors(
override fun toString(): String {
return "TitleBarColors(" +
"background=$background, " +
"inactiveBackground=$inactiveBackground, " +
"content=$content, " +
"border=$border, " +
"fullscreenControlButtonsBackground=$fullscreenControlButtonsBackground, " +
"titlePaneButtonHoveredBackground=$titlePaneButtonHoveredBackground, " +
"titlePaneButtonPressedBackground=$titlePaneButtonPressedBackground, " +
"titlePaneCloseButtonHoveredBackground=$titlePaneCloseButtonHoveredBackground, " +
"titlePaneCloseButtonPressedBackground=$titlePaneCloseButtonPressedBackground, " +
"iconButtonHoveredBackground=$iconButtonHoveredBackground, " +
"iconButtonPressedBackground=$iconButtonPressedBackground, " +
"dropdownPressedBackground=$dropdownPressedBackground, " +
"dropdownHoveredBackground=$dropdownHoveredBackground" +
")"
"background=$background, " +
"inactiveBackground=$inactiveBackground, " +
"content=$content, " +
"border=$border, " +
"fullscreenControlButtonsBackground=$fullscreenControlButtonsBackground, " +
"titlePaneButtonHoveredBackground=$titlePaneButtonHoveredBackground, " +
"titlePaneButtonPressedBackground=$titlePaneButtonPressedBackground, " +
"titlePaneCloseButtonHoveredBackground=$titlePaneCloseButtonHoveredBackground, " +
"titlePaneCloseButtonPressedBackground=$titlePaneCloseButtonPressedBackground, " +
"iconButtonHoveredBackground=$iconButtonHoveredBackground, " +
"iconButtonPressedBackground=$iconButtonPressedBackground, " +
"dropdownPressedBackground=$dropdownPressedBackground, " +
"dropdownHoveredBackground=$dropdownHoveredBackground" +
")"
}
public companion object
@@ -196,11 +196,11 @@ public class TitleBarMetrics(
override fun toString(): String {
return "TitleBarMetrics(" +
"height=$height, " +
"gradientStartX=$gradientStartX, " +
"gradientEndX=$gradientEndX, " +
"titlePaneButtonSize=$titlePaneButtonSize" +
")"
"height=$height, " +
"gradientStartX=$gradientStartX, " +
"gradientEndX=$gradientEndX, " +
"titlePaneButtonSize=$titlePaneButtonSize" +
")"
}
public companion object
@@ -238,11 +238,11 @@ public class TitleBarIcons(
override fun toString(): String {
return "TitleBarIcons(" +
"minimizeButton=$minimizeButton, " +
"maximizeButton=$maximizeButton, " +
"restoreButton=$restoreButton, " +
"closeButton=$closeButton" +
")"
"minimizeButton=$minimizeButton, " +
"maximizeButton=$maximizeButton, " +
"restoreButton=$restoreButton, " +
"closeButton=$closeButton" +
")"
}
public companion object