feat: Version indicator for zls

This commit is contained in:
FalsePattern 2025-03-13 16:29:54 +01:00
parent 2ab3570d08
commit c6af369b1c
Signed by: falsepattern
GPG key ID: E930CDEC50C50E23
5 changed files with 125 additions and 39 deletions

View file

@ -21,6 +21,7 @@ Changelog structure reference:
- LSP
- Error/Warning banner at the top of the editor when ZLS is misconfigured/not running
- ZLS version indicator in the zig settings
- Toolchain
- More descriptive error messages when toolchain detection fails

View file

@ -23,40 +23,18 @@
package com.falsepattern.zigbrains.project.toolchain.tools
import com.falsepattern.zigbrains.project.toolchain.AbstractZigToolchain
import com.falsepattern.zigbrains.shared.cli.call
import com.falsepattern.zigbrains.shared.cli.createCommandLineSafe
import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.process.ProcessOutput
import com.intellij.util.io.awaitExit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.nio.file.Path
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.pathString
abstract class ZigTool(val toolchain: AbstractZigToolchain) {
abstract val toolName: String
suspend fun callWithArgs(workingDirectory: Path?, vararg parameters: String, timeoutMillis: Long = Long.MAX_VALUE): Result<ProcessOutput> {
val cli = createBaseCommandLine(workingDirectory, *parameters).let { it.getOrElse { return Result.failure(it) } }
val (process, exitCode) = withContext(Dispatchers.IO) {
val process = cli.createProcess()
val exit = withTimeoutOrNull(timeoutMillis) {
process.awaitExit()
}
process to exit
}
return runInterruptible {
Result.success(ProcessOutput(
process.inputStream.bufferedReader().use { it.readText() },
process.errorStream.bufferedReader().use { it.readText() },
exitCode ?: -1,
exitCode == null,
false
))
}
return cli.call(timeoutMillis)
}
private suspend fun createBaseCommandLine(
@ -64,15 +42,7 @@ abstract class ZigTool(val toolchain: AbstractZigToolchain) {
vararg parameters: String
): Result<GeneralCommandLine> {
val exe = toolchain.pathToExecutable(toolName)
if (!exe.exists())
return Result.failure(IllegalArgumentException("file does not exist: ${exe.pathString}"))
if (exe.isDirectory())
return Result.failure(IllegalArgumentException("file is a directory: ${exe.pathString}"))
val cli = GeneralCommandLine()
.withExePath(exe.toString())
.withWorkingDirectory(workingDirectory)
.withParameters(*parameters)
.withCharset(Charsets.UTF_8)
return Result.success(toolchain.patchCommandLine(cli))
return createCommandLineSafe(workingDirectory, exe, *parameters)
.mapCatching { toolchain.patchCommandLine(it) }
}
}

View file

@ -23,8 +23,19 @@
package com.falsepattern.zigbrains.shared.cli
import com.falsepattern.zigbrains.ZigBrainsBundle
import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.process.ProcessOutput
import com.intellij.openapi.options.ConfigurationException
import com.intellij.util.io.awaitExit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.nio.file.Path
import java.util.*
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.pathString
//From Apache Ant
@ -101,3 +112,39 @@ fun coloredCliFlags(colored: Boolean, debug: Boolean): List<String> {
listOf("--color", if (colored) "on" else "off")
}
}
fun createCommandLineSafe(
workingDirectory: Path?,
exe: Path,
vararg parameters: String,
): Result<GeneralCommandLine> {
if (!exe.exists())
return Result.failure(IllegalArgumentException("file does not exist: ${exe.pathString}"))
if (exe.isDirectory())
return Result.failure(IllegalArgumentException("file is a directory: ${exe.pathString}"))
val cli = GeneralCommandLine()
.withExePath(exe.toString())
.withWorkingDirectory(workingDirectory)
.withParameters(*parameters)
.withCharset(Charsets.UTF_8)
return Result.success(cli)
}
suspend fun GeneralCommandLine.call(timeoutMillis: Long = Long.MAX_VALUE): Result<ProcessOutput> {
val (process, exitCode) = withContext(Dispatchers.IO) {
val process = createProcess()
val exit = withTimeoutOrNull(timeoutMillis) {
process.awaitExit()
}
process to exit
}
return runInterruptible {
Result.success(ProcessOutput(
process.inputStream.bufferedReader().use { it.readText() },
process.errorStream.bufferedReader().use { it.readText() },
exitCode ?: -1,
exitCode == null,
false
))
}
}

View file

@ -29,24 +29,38 @@ import com.falsepattern.zigbrains.direnv.getDirenv
import com.falsepattern.zigbrains.lsp.ZLSBundle
import com.falsepattern.zigbrains.lsp.config.SemanticTokens
import com.falsepattern.zigbrains.project.settings.ZigProjectConfigurationProvider
import com.falsepattern.zigbrains.shared.cli.call
import com.falsepattern.zigbrains.shared.cli.createCommandLineSafe
import com.falsepattern.zigbrains.shared.coroutine.launchWithEDT
import com.falsepattern.zigbrains.shared.coroutine.withEDTContext
import com.falsepattern.zigbrains.shared.zigCoroutineScope
import com.intellij.execution.processTools.mapFlat
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.guessProjectDir
import com.intellij.openapi.ui.ComboBox
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.io.toNioPathOrNull
import com.intellij.openapi.vfs.toNioPathOrNull
import com.intellij.platform.ide.progress.ModalTaskOwner
import com.intellij.platform.ide.progress.TaskCancellation
import com.intellij.platform.ide.progress.withModalProgress
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.JBColor
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBTextArea
import com.intellij.ui.components.fields.ExtendableTextField
import com.intellij.ui.components.textFieldWithBrowseButton
import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.Panel
import com.intellij.ui.dsl.builder.Row
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.annotations.PropertyKey
import java.lang.IllegalArgumentException
import java.util.*
import javax.swing.event.DocumentEvent
import kotlin.io.path.pathString
@Suppress("PrivatePropertyName")
@ -55,13 +69,24 @@ class ZLSSettingsPanel(private val project: Project) : ZigProjectConfigurationPr
project,
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor()
.withTitle(ZLSBundle.message("settings.zls-path.browse.title")),
).also { Disposer.register(this, it) }
).also {
it.textField.document.addDocumentListener(object: DocumentAdapter() {
override fun textChanged(p0: DocumentEvent) {
dispatchUpdateUI()
}
})
Disposer.register(this, it)
}
private val zlsConfigPath = textFieldWithBrowseButton(
project,
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor()
.withTitle(ZLSBundle.message("settings.zls-config-path.browse.title"))
).also { Disposer.register(this, it) }
private val zlsVersion = JBTextArea().also { it.isEditable = false }
private var debounce: Job? = null
private val inlayHints = JBCheckBox()
private val enable_snippets = JBCheckBox()
private val enable_argument_placeholders = JBCheckBox()
@ -102,6 +127,9 @@ class ZLSSettingsPanel(private val project: Project) : ZigProjectConfigurationPr
cell(direnv)
}
}
row(ZLSBundle.message("settings.zls-version.label")) {
cell(zlsVersion)
}
fancyRow(
"settings.zls-config-path.label",
"settings.zls-config-path.tooltip"
@ -250,6 +278,7 @@ class ZLSSettingsPanel(private val project: Project) : ZigProjectConfigurationPr
builtin_path.text = value.builtin_path ?: ""
build_runner_path.text = value.build_runner_path ?: ""
global_cache_path.text = value.global_cache_path ?: ""
dispatchUpdateUI()
}
private fun dispatchAutodetect(force: Boolean) {
@ -265,12 +294,14 @@ class ZLSSettingsPanel(private val project: Project) : ZigProjectConfigurationPr
getDirenv().findExecutableOnPATH("zls")?.let {
if (force || zlsPath.text.isBlank()) {
zlsPath.text = it.pathString
dispatchUpdateUI()
}
}
}
}
override fun dispose() {
debounce?.cancel("Disposed")
}
private suspend fun getDirenv(): Env {
@ -278,6 +309,42 @@ class ZLSSettingsPanel(private val project: Project) : ZigProjectConfigurationPr
return project.getDirenv()
return emptyEnv
}
private fun dispatchUpdateUI() {
debounce?.cancel("New debounce")
debounce = project.zigCoroutineScope.launch {
updateUI()
}
}
private suspend fun updateUI() {
if (project.isDefault)
return
delay(200)
val zlsPath = this.zlsPath.text.ifBlank { null }?.toNioPathOrNull()
if (zlsPath == null) {
withEDTContext(ModalityState.any()) {
zlsVersion.text = "[zls path empty or invalid]"
}
return
}
val workingDir = project.guessProjectDir()?.toNioPathOrNull()
val result = createCommandLineSafe(workingDir, zlsPath, "version")
.map { it.withEnvironment(getDirenv().env) }
.mapFlat { it.call() }
.getOrElse { throwable ->
throwable.printStackTrace()
withEDTContext(ModalityState.any()) {
zlsVersion.text = "[failed to run \"zls version\"]\n${throwable.message}"
}
return
}
val version = result.stdout
withEDTContext(ModalityState.any()) {
zlsVersion.text = version
zlsVersion.foreground = JBColor.foreground()
}
}
}
private fun Panel.fancyRow(

View file

@ -2,6 +2,7 @@ settings.group.title=ZLS Settings
settings.zls-path.label=Executable path
settings.zls-path.tooltip=Path to the ZLS Binary
settings.zls-path.browse.title=Path to the ZLS Binary
settings.zls-version.label=Detected ZLS version
settings.zls-config-path.label=Config path
settings.zls-config-path.tooltip=Leave empty to use built-in config generated from the settings below
settings.zls-config-path.browse.title=Path to the Custom ZLS Config File (Optional)