From 29e991c380bc5fef391a3f295980a12b13643491 Mon Sep 17 00:00:00 2001 From: Josh Friend Date: Fri, 6 Feb 2026 08:54:56 -0500 Subject: [PATCH] Implement BuildLayoutParameters so hermit Gradle is used by all IDE execution paths GradleExecutionHelper and high-level APIs like ExternalSystemUtil.runTask resolve the Gradle distribution via GradleExecutionAware.getBuildLayoutParameters, which previously returned null from this plugin, causing the IDE to fall back to its bundled Gradle when no wrapper properties file exists. Also adds a path-existence guard in HermitJdkUpdater to surface a user-facing error instead of crashing when the JDK home doesn't exist on disk. --- .../kotlin/com/squareup/cash/hermit/Hermit.kt | 6 +- .../gradle/HermitBuildLayoutParameters.kt | 72 +++++++++ .../gradle/HermitGradleExecutionAware.kt | 32 +++- .../cash/hermit/idea/HermitJdkUpdater.kt | 9 +- .../com/squareup/cash/hermit/FakeHermit.kt | 19 +-- .../gradle/HermitGradleExecutionAwareTest.kt | 138 ++++++++++++++++++ 6 files changed, 257 insertions(+), 19 deletions(-) create mode 100644 src/main/kotlin/com/squareup/cash/hermit/gradle/HermitBuildLayoutParameters.kt create mode 100644 src/test/kotlin/com/squareup/cash/hermit/gradle/HermitGradleExecutionAwareTest.kt diff --git a/src/main/kotlin/com/squareup/cash/hermit/Hermit.kt b/src/main/kotlin/com/squareup/cash/hermit/Hermit.kt index 64ea4a2..137a390 100644 --- a/src/main/kotlin/com/squareup/cash/hermit/Hermit.kt +++ b/src/main/kotlin/com/squareup/cash/hermit/Hermit.kt @@ -205,6 +205,10 @@ object Hermit { return bin?.findChild(exe)?.exists() ?: false } + fun findPackage(type: PackageType): HermitPackage? { + return this.properties?.packages?.find { it.type == type } + } + private fun clear() { this.properties = null this.isHermitProject = false @@ -236,4 +240,4 @@ object Hermit { return ImmutableMap.copyOf(env) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/squareup/cash/hermit/gradle/HermitBuildLayoutParameters.kt b/src/main/kotlin/com/squareup/cash/hermit/gradle/HermitBuildLayoutParameters.kt new file mode 100644 index 0000000..b1fc588 --- /dev/null +++ b/src/main/kotlin/com/squareup/cash/hermit/gradle/HermitBuildLayoutParameters.kt @@ -0,0 +1,72 @@ +package com.squareup.cash.hermit.gradle + +import com.intellij.execution.target.value.TargetValue +import com.intellij.openapi.project.Project +import org.gradle.util.GradleVersion +import org.jetbrains.plugins.gradle.service.GradleInstallationManager +import org.jetbrains.plugins.gradle.service.execution.BuildLayoutParameters +import org.jetbrains.plugins.gradle.service.execution.gradleUserHomeDir +import org.jetbrains.plugins.gradle.settings.GradleSettings +import java.nio.file.Files +import java.nio.file.Path +import java.util.regex.Pattern + +private val GRADLE_JAR_PATTERN = Pattern.compile(System.getProperty("gradle.pattern.core.jar", "gradle-(core-)?(\\d.*)\\.jar")) + +/** + * [BuildLayoutParameters] that points to a hermit-managed Gradle installation. + * The [gradleUserHomePath] resolution matches IntelliJ's [LocalBuildLayoutParameters]: + * project Gradle settings first, then GRADLE_USER_HOME env/property, then ~/.gradle. + */ +class HermitBuildLayoutParameters( + private val hermitGradleHome: Path, + project: Project +) : BuildLayoutParameters { + + override val gradleHome: TargetValue = TargetValue.fixed(hermitGradleHome) + + override val gradleVersion: GradleVersion? by lazy { + detectGradleVersion(hermitGradleHome) + } + + override val gradleUserHomePath: TargetValue by lazy { + val serviceDir = GradleSettings.getInstance(project).serviceDirectoryPath + val userHome = if (serviceDir != null) { + Path.of(serviceDir) + } else { + gradleUserHomeDir().toPath() + } + TargetValue.fixed(userHome) + } +} + +/** + * Detects the Gradle version from a Gradle home directory by inspecting jar filenames in `lib/`. + * Inlined from [GradleInstallationManager] to avoid binary compatibility issues with the Companion + * object across older IntelliJ versions. + */ +private fun detectGradleVersion(gradleHome: Path): GradleVersion? { + val libs = gradleHome.resolve("lib") + if (!Files.isDirectory(libs)) return null + + val versionString = try { + Files.list(libs).use { children -> + children + .map { GRADLE_JAR_PATTERN.matcher(it.fileName.toString()) } + .filter { it.matches() } + .map { it.group(2) } + .findFirst() + .orElse(null) + } + } catch (_: Exception) { + null + } ?: return null + + return try { + GradleVersion.version(versionString) + } catch (_: IllegalArgumentException) { + // GradleVersion.version(gradleVersion) might throw exception for custom Gradle versions + // https://youtrack.jetbrains.com/issue/IDEA-216892 + null + } +} diff --git a/src/main/kotlin/com/squareup/cash/hermit/gradle/HermitGradleExecutionAware.kt b/src/main/kotlin/com/squareup/cash/hermit/gradle/HermitGradleExecutionAware.kt index befbdce..d90e2b8 100644 --- a/src/main/kotlin/com/squareup/cash/hermit/gradle/HermitGradleExecutionAware.kt +++ b/src/main/kotlin/com/squareup/cash/hermit/gradle/HermitGradleExecutionAware.kt @@ -1,3 +1,5 @@ +@file:Suppress("UnstableApiUsage") + package com.squareup.cash.hermit.gradle import com.intellij.openapi.diagnostic.Logger @@ -5,9 +7,10 @@ import com.intellij.openapi.externalSystem.model.task.ExternalSystemTask import com.intellij.openapi.externalSystem.model.task.ExternalSystemTaskNotificationListener import com.intellij.openapi.project.Project import com.squareup.cash.hermit.Hermit -import org.jetbrains.annotations.ApiStatus +import com.squareup.cash.hermit.PackageType import org.jetbrains.plugins.gradle.service.execution.BuildLayoutParameters import org.jetbrains.plugins.gradle.service.execution.GradleExecutionAware +import java.nio.file.Path class HermitGradleExecutionAware: GradleExecutionAware { private val log: Logger = Logger.getInstance(this.javaClass) @@ -42,13 +45,32 @@ class HermitGradleExecutionAware: GradleExecutionAware { } } - override fun getBuildLayoutParameters(project: Project, projectPath: String): BuildLayoutParameters? = null + override fun getBuildLayoutParameters(project: Project, projectPath: Path): BuildLayoutParameters? { + val gradleHome = hermitGradleHome(project) ?: return null + log.debug("using hermit Gradle home: $gradleHome") + return HermitBuildLayoutParameters(gradleHome, project) + } + + override fun getDefaultBuildLayoutParameters(project: Project): BuildLayoutParameters? { + val gradleHome = hermitGradleHome(project) ?: return null + log.debug("using hermit Gradle home for default build layout: $gradleHome") + return HermitBuildLayoutParameters(gradleHome, project) + } - override fun getDefaultBuildLayoutParameters(project: Project): BuildLayoutParameters? = null + override fun isGradleInstallationHomeDir(project: Project, homePath: Path): Boolean { + val gradleHome = hermitGradleHome(project) ?: return false + return homePath == gradleHome + } - override fun isGradleInstallationHomeDir(project: Project, homePath: String): Boolean = false + private fun hermitGradleHome(project: Project): Path? { + val state = Hermit(project) + if (!state.hasHermit() || state.hermitStatus() == Hermit.HermitStatus.Disabled) return null + val gradlePkg = state.findPackage(PackageType.Gradle) ?: return null + val path = Path.of(gradlePkg.path) + return if (path.toFile().isDirectory) path else null + } companion object { const val TIMEOUT_MS = 120000 } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/squareup/cash/hermit/idea/HermitJdkUpdater.kt b/src/main/kotlin/com/squareup/cash/hermit/idea/HermitJdkUpdater.kt index 98b597e..bebdd13 100644 --- a/src/main/kotlin/com/squareup/cash/hermit/idea/HermitJdkUpdater.kt +++ b/src/main/kotlin/com/squareup/cash/hermit/idea/HermitJdkUpdater.kt @@ -4,15 +4,20 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project import com.intellij.openapi.projectRoots.ProjectJdkTable -import com.intellij.openapi.projectRoots.impl.ProjectJdkImpl -import com.intellij.openapi.util.Disposer import com.squareup.cash.hermit.* +import java.io.File class HermitJdkUpdater : HermitPropertyHandler { private val log: Logger = Logger.getInstance(this.javaClass) override fun handle(hermitPackage: HermitPackage, project: Project) { if (hermitPackage.type == PackageType.JDK) { + if (!File(hermitPackage.path).isDirectory) { + log.warn("JDK path does not exist: ${hermitPackage.path}, skipping SDK configuration") + UI.showError(project, "Hermit JDK path does not exist: ${hermitPackage.path}. " + + "Try running hermit install from the terminal.") + return + } val projectSdk = project.projectSdk() if (hermitPackage.sdkName() != projectSdk?.name) { log.debug("setting project (" + project.name + ") SDK to " + hermitPackage.logString()) diff --git a/src/test/kotlin/com/squareup/cash/hermit/FakeHermit.kt b/src/test/kotlin/com/squareup/cash/hermit/FakeHermit.kt index 0f5d93c..bb38b52 100644 --- a/src/test/kotlin/com/squareup/cash/hermit/FakeHermit.kt +++ b/src/test/kotlin/com/squareup/cash/hermit/FakeHermit.kt @@ -35,14 +35,11 @@ object BrokenHermit : AbstractHermit() { data class FakeHermit(val packages: List) : AbstractHermit() { override fun writeTo(path: Path) { - val packageList = packages - .map { "echo \"${it.name}\"" } - .joinToString("\n") - val envList = packages - .flatMap { it.env.entries } - .map { entry -> "echo \"${entry.key}=${entry.value}\""} - .joinToString("\n") - val infoList = JsonArray(packages + val packageList = packages.joinToString("\n") { "echo \"${it.name}\"" } + val envList = packages + .flatMap { it.env.entries } + .joinToString("\n") { entry -> "echo \"${entry.key}=${entry.value}\"" } + val infoList = JsonArray(packages .map { p -> JsonObject(mapOf( "Reference" to JsonObject(mapOf( "Name" to JsonPrimitive(p.name), @@ -54,13 +51,13 @@ data class FakeHermit(val packages: List) : AbstractHermit() { )) }).toString() val listBlock = if (packageList.isNotEmpty()) """ - if [ ${'$'}1 == "list" ]; then + if [ $1 == "list" ]; then $packageList fi """.trimIndent() else "" val envBlock = if (envList.isNotEmpty()) """ - if [ ${'$'}1 == "env" ]; then + if [ $1 == "env" ]; then $envList fi """.trimIndent() @@ -85,4 +82,4 @@ fun String.runCommand(workingDir: Path) { builder.environment()["HERMIT_ENV"] = workingDir.toString() builder.start().waitFor(60, TimeUnit.SECONDS) -} \ No newline at end of file +} diff --git a/src/test/kotlin/com/squareup/cash/hermit/gradle/HermitGradleExecutionAwareTest.kt b/src/test/kotlin/com/squareup/cash/hermit/gradle/HermitGradleExecutionAwareTest.kt new file mode 100644 index 0000000..5202a9a --- /dev/null +++ b/src/test/kotlin/com/squareup/cash/hermit/gradle/HermitGradleExecutionAwareTest.kt @@ -0,0 +1,138 @@ +package com.squareup.cash.hermit.gradle + +import com.intellij.execution.target.value.TargetValue +import com.squareup.cash.hermit.FakeHermit +import com.squareup.cash.hermit.Hermit +import com.squareup.cash.hermit.HermitProjectTestCase +import com.squareup.cash.hermit.PackageType +import com.squareup.cash.hermit.TestPackage +import junit.framework.TestCase +import org.junit.Test +import java.nio.file.Files +import java.nio.file.Path + +class HermitGradleExecutionAwareTest : HermitProjectTestCase() { + private val aware = HermitGradleExecutionAware() + + private fun gradleRoot(): Path { + val dir = projectDirOrFile.parent.resolve("fake-gradle-home") + Files.createDirectories(dir) + return dir + } + + private fun localValue(targetValue: TargetValue): T? { + return targetValue.localValue.blockingGet(0) + } + + @Test fun `test getBuildLayoutParameters returns null when hermit has no gradle package`() { + withHermit(FakeHermit(listOf(TestPackage("openjdk", "21", "", "/nonexistent/jdk/path", emptyMap())))) + Hermit(project).enable() + waitAppThreads() + + val params = aware.getBuildLayoutParameters(project, projectDirOrFile.parent) + assertNull(params) + } + + @Test fun `test getBuildLayoutParameters returns null when hermit is not enabled`() { + val params = aware.getBuildLayoutParameters(project, projectDirOrFile.parent) + assertNull(params) + } + + @Test + fun `test getBuildLayoutParameters returns hermit gradle home when gradle package exists`() { + val root = gradleRoot() + withHermit(FakeHermit(listOf(TestPackage("gradle", "9.3.1", "", root.toString(), emptyMap())))) + Hermit(project).enable() + waitAppThreads() + + val params = aware.getBuildLayoutParameters(project, projectDirOrFile.parent)!! + assertEquals(root, localValue(params.gradleHome!!)) + } + + @Test fun `test getDefaultBuildLayoutParameters returns null when no gradle package`() { + withHermit(FakeHermit(emptyList())) + Hermit(project).enable() + waitAppThreads() + + val params = aware.getDefaultBuildLayoutParameters(project) + assertNull(params) + } + + @Test + fun `test getDefaultBuildLayoutParameters returns hermit gradle home when gradle package exists`() { + val root = gradleRoot() + withHermit(FakeHermit(listOf(TestPackage("gradle", "9.3.1", "", root.toString(), emptyMap())))) + Hermit(project).enable() + waitAppThreads() + + val params = aware.getDefaultBuildLayoutParameters(project)!! + assertEquals(root, localValue(params.gradleHome!!)) + } + + @Test fun `test isGradleInstallationHomeDir returns true for hermit gradle home`() { + val root = gradleRoot() + withHermit(FakeHermit(listOf(TestPackage("gradle", "9.3.1", "", root.toString(), emptyMap())))) + Hermit(project).enable() + waitAppThreads() + + assertTrue(aware.isGradleInstallationHomeDir(project, root)) + } + + @Test fun `test isGradleInstallationHomeDir returns false for non-hermit path`() { + val root = gradleRoot() + withHermit(FakeHermit(listOf(TestPackage("gradle", "9.3.1", "", root.toString(), emptyMap())))) + Hermit(project).enable() + waitAppThreads() + + assertFalse(aware.isGradleInstallationHomeDir(project, Path.of("/some/other/path"))) + } + + @Test fun `test isGradleInstallationHomeDir returns false when hermit has no gradle`() { + withHermit(FakeHermit(emptyList())) + Hermit(project).enable() + waitAppThreads() + + assertFalse(aware.isGradleInstallationHomeDir(project, Path.of("/some/path"))) + } + + @Test fun `test gradlePackage returns the gradle package after enable`() { + val root = gradleRoot() + withHermit(FakeHermit(listOf(TestPackage("gradle", "9.3.1", "", root.toString(), emptyMap())))) + Hermit(project).enable() + waitAppThreads() + + val pkg = Hermit(project).findPackage(PackageType.Gradle)!! + TestCase.assertEquals("gradle", pkg.name) + TestCase.assertEquals("9.3.1", pkg.version) + TestCase.assertEquals(root.toString(), pkg.path) + } + + @Test fun `test gradlePackage returns null when no gradle package`() { + withHermit(FakeHermit(listOf(TestPackage("openjdk", "21", "", "/nonexistent/jdk/path", emptyMap())))) + Hermit(project).enable() + waitAppThreads() + + assertNull(Hermit(project).findPackage(PackageType.Gradle)) + } + + @Test fun `test build layout parameters provides gradle user home`() { + val root = gradleRoot() + withHermit(FakeHermit(listOf(TestPackage("gradle", "9.3.1", "", root.toString(), emptyMap())))) + Hermit(project).enable() + waitAppThreads() + + val params = aware.getBuildLayoutParameters(project, projectDirOrFile.parent)!! + val userHome = localValue(params.gradleUserHomePath) + assertNotNull(userHome) + } + + @Test fun `test getBuildLayoutParameters returns null when gradle path does not exist`() { + val nonExistent = "/tmp/hermit-test-nonexistent-gradle-home-${System.nanoTime()}" + withHermit(FakeHermit(listOf(TestPackage("gradle", "9.3.1", "", nonExistent, emptyMap())))) + Hermit(project).enable() + waitAppThreads() + + val params = aware.getBuildLayoutParameters(project, projectDirOrFile.parent) + assertNull(params) + } +}