Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions editors/intellij/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@

## Usage

You must [have Tombi installed][2] to use this plugin.
See Tombi's documentation for more information.
The plugin uses a Tombi executable already available in the project or on
`PATH`. If one is not available, it downloads the latest Tombi release
automatically.

Once Tombi is installed, open any TOML file and start working.
You can select a specific executable in the plugin settings. Open any TOML file
to start working.


## Logging
Expand All @@ -27,7 +29,6 @@ com.intellij.platform.lsp


[1]: https://tombi-toml.github.io/tombi
[2]: https://tombi-toml.github.io/tombi/docs/installation
<!-- Plugin description end -->


Expand Down
1 change: 1 addition & 0 deletions editors/intellij/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ repositories {

// Dependencies are managed with Gradle version catalog - read more: https://docs.gradle.org/current/userguide/platforms.html#sub:version-catalog
dependencies {
implementation(libs.commonsCompress)
testImplementation(kotlin("test"))
testImplementation(libs.junit)

Expand Down
6 changes: 6 additions & 0 deletions editors/intellij/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[versions]
# libraries
annotations = "26.0.2-1"
commonsCompress = "1.28.0"
junit = "4.13.2"
# plugins
changelog = "2.4.0"
Expand All @@ -14,6 +15,11 @@ group = "org.jetbrains"
name = "annotations"
version.ref = "annotations"

[libraries.commonsCompress]
group = "org.apache.commons"
name = "commons-compress"
version.ref = "commonsCompress"

[libraries.junit]
group = "junit"
name = "junit"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ internal class TombiConfigurations : BaseState() {
/**
* The Tombi executable to be used in commands.
*
* Defaults to `tombi` if not specified.
* When unspecified, the plugin looks for a local Tombi installation and
* downloads the latest release if none is available.
*/
var executable by string("tombi")
var executable by string()
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ private fun Row.executableInput(block: Cell<TextFieldWithBrowseButton>.() -> Uni
internal fun makePanel(state: TombiConfigurations) = panel {
row(message("configurations.executable.label")) {
executableInput {
bindText(state::executable.toNonNullableProperty("tombi"))
component.emptyText.text = "tombi"
bindText(state::executable.toNonNullableProperty(""))
component.emptyText.text = message("configurations.executable.placeholder")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
package tombi.server

import com.intellij.openapi.application.PathManager
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
import java.io.BufferedInputStream
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.StandardCopyOption
import java.nio.file.attribute.PosixFilePermission
import java.time.Duration


internal object TombiBinaryDownloader {
private const val RELEASES_URL = "https://github.com/tombi-toml/tombi/releases"
private val versionPattern = Regex("""v?(\d+\.\d+\.\d+)""")

@Synchronized
fun downloadLatestOrCached(
cacheDirectory: Path = PathManager.getSystemDir().resolve("tombi"),
client: HttpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.followRedirects(HttpClient.Redirect.ALWAYS)
.build(),
osName: String = System.getProperty("os.name"),
architecture: String = System.getProperty("os.arch"),
): String {
return try {
downloadLatest(cacheDirectory, client, platform(osName, architecture)).toString()
} catch (error: Exception) {
newestCachedBinary(cacheDirectory)?.toString() ?: throw error
}
}

internal fun platform(osName: String, architecture: String): Platform {
val targetArchitecture = when (architecture.lowercase()) {
"aarch64", "arm64" -> "aarch64"
"x86_64", "amd64", "x64" -> "x86_64"
else -> error("Unsupported architecture: $architecture")
}
val normalizedOsName = osName.lowercase()
val (targetOs, archiveType) = when {
normalizedOsName.startsWith("mac") ->
"apple-darwin" to ArchiveType.TAR_GZ
normalizedOsName.startsWith("linux") ->
"unknown-linux-musl" to ArchiveType.TAR_GZ
normalizedOsName.startsWith("windows") ->
"pc-windows-msvc" to ArchiveType.ZIP
else -> error("Unsupported operating system: $osName")
}

return Platform(
target = "$targetArchitecture-$targetOs",
binaryName = if (archiveType == ArchiveType.ZIP) "tombi.exe" else "tombi",
archiveType = archiveType,
)
}

internal fun versionFromLatestReleaseUri(uri: URI): String {
val tag = uri.path.substringAfterLast('/')
return versionPattern.matchEntire(tag)?.groupValues?.get(1)
?: error("Unexpected latest release URL: $uri")
}

private fun downloadLatest(
cacheDirectory: Path,
client: HttpClient,
platform: Platform,
): Path {
val latestRequest = HttpRequest.newBuilder(URI.create("$RELEASES_URL/latest"))
.timeout(Duration.ofSeconds(30))
.GET()
.build()
val latestResponse = client.send(latestRequest, HttpResponse.BodyHandlers.discarding())
requireSuccess(latestResponse.statusCode(), latestResponse.uri())

val version = versionFromLatestReleaseUri(latestResponse.uri())
val versionDirectory = cacheDirectory.resolve("tombi-$version")
val binaryPath = versionDirectory.resolve(platform.binaryName)
if (Files.isRegularFile(binaryPath)) {
return binaryPath
}

Files.createDirectories(versionDirectory)
val assetName = "tombi-cli-$version-${platform.target}.${platform.archiveType.extension}"
val assetUri = URI.create("$RELEASES_URL/download/v$version/$assetName")
val archivePath = Files.createTempFile(versionDirectory, "download-", ".${platform.archiveType.extension}")
val extractedPath = Files.createTempFile(versionDirectory, "tombi-", ".tmp")

try {
val assetRequest = HttpRequest.newBuilder(assetUri)
.timeout(Duration.ofMinutes(2))
.GET()
.build()
val assetResponse = client.send(assetRequest, HttpResponse.BodyHandlers.ofFile(archivePath))
requireSuccess(assetResponse.statusCode(), assetResponse.uri())

BufferedInputStream(Files.newInputStream(archivePath)).use { input ->
Files.newOutputStream(extractedPath).use { output ->
when (platform.archiveType) {
ArchiveType.TAR_GZ ->
extractFromTarGz(input, platform.binaryName, output)
ArchiveType.ZIP ->
extractFromZip(input, platform.binaryName, output)
}
}
}
makeExecutable(extractedPath)
moveIntoPlace(extractedPath, binaryPath)
} finally {
Files.deleteIfExists(archivePath)
Files.deleteIfExists(extractedPath)
}

return binaryPath
}

private fun extractFromTarGz(
input: java.io.InputStream,
binaryName: String,
output: java.io.OutputStream,
) {
TarArchiveInputStream(GzipCompressorInputStream(input)).use { archive ->
while (true) {
val entry = archive.nextEntry ?: break
if (entry.isFile && Path.of(entry.name).fileName.toString() == binaryName) {
archive.copyTo(output)
return
}
}
}
error("$binaryName was not found in the downloaded archive")
}

private fun extractFromZip(
input: java.io.InputStream,
binaryName: String,
output: java.io.OutputStream,
) {
ZipArchiveInputStream(input).use { archive ->
while (true) {
val entry = archive.nextEntry ?: break
if (!entry.isDirectory && Path.of(entry.name).fileName.toString() == binaryName) {
archive.copyTo(output)
return
}
}
}
error("$binaryName was not found in the downloaded archive")
}

private fun makeExecutable(binaryPath: Path) {
try {
val permissions = Files.getPosixFilePermissions(binaryPath)
permissions.addAll(
setOf(
PosixFilePermission.OWNER_EXECUTE,
PosixFilePermission.GROUP_EXECUTE,
PosixFilePermission.OTHERS_EXECUTE,
),
)
Files.setPosixFilePermissions(binaryPath, permissions)
} catch (_: UnsupportedOperationException) {
binaryPath.toFile().setExecutable(true, false)
}
}

private fun moveIntoPlace(source: Path, target: Path) {
try {
Files.move(
source,
target,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING)
}
}

private fun newestCachedBinary(cacheDirectory: Path): Path? {
if (!Files.isDirectory(cacheDirectory)) {
return null
}

return Files.list(cacheDirectory).use { entries ->
entries
.map { directory ->
val version = versionPattern.matchEntire(directory.fileName.toString().removePrefix("tombi-"))
?.groupValues
?.get(1)
val binary = listOf("tombi", "tombi.exe")
.map(directory::resolve)
.firstOrNull(Files::isRegularFile)
if (version != null && binary != null) {
Triple(versionComponents(version), version, binary)
} else {
null
}
}
.filter { it != null }
.map { it!! }
.max { left, right -> compareVersions(left.first, right.first) }
.orElse(null)
?.third
}
}

private fun versionComponents(version: String): List<Int> =
version.split('.').map(String::toInt)

private fun compareVersions(left: List<Int>, right: List<Int>): Int {
for (index in 0 until maxOf(left.size, right.size)) {
val comparison = left.getOrElse(index) { 0 }.compareTo(right.getOrElse(index) { 0 })
if (comparison != 0) {
return comparison
}
}
return 0
}

private fun requireSuccess(statusCode: Int, uri: URI) {
check(statusCode in 200..299) {
"Request to $uri failed with HTTP $statusCode"
}
}

internal data class Platform(
val target: String,
val binaryName: String,
val archiveType: ArchiveType,
)

internal enum class ArchiveType(val extension: String) {
TAR_GZ("tar.gz"),
ZIP("zip"),
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package tombi.server

import com.intellij.util.EnvironmentUtil
import java.nio.file.Files
import java.nio.file.Path


internal object TombiBinaryResolver {
fun resolveLocal(
projectPath: Path?,
configuredExecutable: String?,
environment: Map<String, String> = EnvironmentUtil.getEnvironmentMap(),
osName: String = System.getProperty("os.name"),
userHome: String = System.getProperty("user.home"),
): String? {
configuredExecutable
?.trim()
?.takeIf(String::isNotEmpty)
?.let { return expandHome(it, userHome) }

val isWindows = osName.startsWith("Windows", ignoreCase = true)
val binaryName = if (isWindows) "tombi.exe" else "tombi"

if (projectPath != null) {
val virtualEnvironmentDirectory = if (isWindows) "Scripts" else "bin"
val projectCandidates = mutableListOf(
projectPath.resolve(".venv").resolve(virtualEnvironmentDirectory).resolve(binaryName),
projectPath.resolve("node_modules").resolve(".bin").resolve(binaryName),
)
if (isWindows) {
projectCandidates.addAll(
listOf("tombi.cmd", "tombi.ps1").map {
projectPath.resolve("node_modules").resolve(".bin").resolve(it)
},
)
}
projectCandidates.firstOrNull(Files::isRegularFile)?.let {
return it.toString()
}
}

return findOnPath(binaryName, environment, isWindows)
}

private fun expandHome(path: String, userHome: String): String =
when {
path == "~" -> userHome
path.startsWith("~/") || path.startsWith("~\\") -> userHome + path.drop(1)
else -> path
}

private fun findOnPath(
binaryName: String,
environment: Map<String, String>,
isWindows: Boolean,
): String? {
val pathValue = environment.entries
.firstOrNull { (key, _) -> key.equals("PATH", ignoreCase = isWindows) }
?.value
?: return null

val separator = if (isWindows) ';' else ':'
val candidateNames = if (isWindows) {
listOf(binaryName, "tombi.cmd", "tombi.bat")
} else {
listOf(binaryName)
}

return pathValue
.split(separator)
.asSequence()
.filter(String::isNotBlank)
.flatMap { directory -> candidateNames.asSequence().map(Path.of(directory)::resolve) }
.firstOrNull(Files::isRegularFile)
?.toString()
}
}
Loading
Loading