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 - LSP
- Error/Warning banner at the top of the editor when ZLS is misconfigured/not running - Error/Warning banner at the top of the editor when ZLS is misconfigured/not running
- ZLS version indicator in the zig settings
- Toolchain - Toolchain
- More descriptive error messages when toolchain detection fails - More descriptive error messages when toolchain detection fails

View file

@ -23,40 +23,18 @@
package com.falsepattern.zigbrains.project.toolchain.tools package com.falsepattern.zigbrains.project.toolchain.tools
import com.falsepattern.zigbrains.project.toolchain.AbstractZigToolchain 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.configurations.GeneralCommandLine
import com.intellij.execution.process.ProcessOutput 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 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 class ZigTool(val toolchain: AbstractZigToolchain) {
abstract val toolName: String abstract val toolName: String
suspend fun callWithArgs(workingDirectory: Path?, vararg parameters: String, timeoutMillis: Long = Long.MAX_VALUE): Result<ProcessOutput> { 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 cli = createBaseCommandLine(workingDirectory, *parameters).let { it.getOrElse { return Result.failure(it) } }
return cli.call(timeoutMillis)
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
))
}
} }
private suspend fun createBaseCommandLine( private suspend fun createBaseCommandLine(
@ -64,15 +42,7 @@ abstract class ZigTool(val toolchain: AbstractZigToolchain) {
vararg parameters: String vararg parameters: String
): Result<GeneralCommandLine> { ): Result<GeneralCommandLine> {
val exe = toolchain.pathToExecutable(toolName) val exe = toolchain.pathToExecutable(toolName)
if (!exe.exists()) return createCommandLineSafe(workingDirectory, exe, *parameters)
return Result.failure(IllegalArgumentException("file does not exist: ${exe.pathString}")) .mapCatching { toolchain.patchCommandLine(it) }
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))
} }
} }

View file

@ -23,8 +23,19 @@
package com.falsepattern.zigbrains.shared.cli package com.falsepattern.zigbrains.shared.cli
import com.falsepattern.zigbrains.ZigBrainsBundle 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.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 java.util.*
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.pathString
//From Apache Ant //From Apache Ant
@ -101,3 +112,39 @@ fun coloredCliFlags(colored: Boolean, debug: Boolean): List<String> {
listOf("--color", if (colored) "on" else "off") 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.ZLSBundle
import com.falsepattern.zigbrains.lsp.config.SemanticTokens import com.falsepattern.zigbrains.lsp.config.SemanticTokens
import com.falsepattern.zigbrains.project.settings.ZigProjectConfigurationProvider 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.launchWithEDT
import com.falsepattern.zigbrains.shared.coroutine.withEDTContext
import com.falsepattern.zigbrains.shared.zigCoroutineScope 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.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.project.Project import com.intellij.openapi.project.Project
import com.intellij.openapi.project.guessProjectDir
import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.ui.ComboBox
import com.intellij.openapi.util.Disposer 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.ModalTaskOwner
import com.intellij.platform.ide.progress.TaskCancellation import com.intellij.platform.ide.progress.TaskCancellation
import com.intellij.platform.ide.progress.withModalProgress 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.JBCheckBox
import com.intellij.ui.components.JBTextArea
import com.intellij.ui.components.fields.ExtendableTextField import com.intellij.ui.components.fields.ExtendableTextField
import com.intellij.ui.components.textFieldWithBrowseButton import com.intellij.ui.components.textFieldWithBrowseButton
import com.intellij.ui.dsl.builder.AlignX import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.Panel import com.intellij.ui.dsl.builder.Panel
import com.intellij.ui.dsl.builder.Row 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 org.jetbrains.annotations.PropertyKey
import java.lang.IllegalArgumentException import javax.swing.event.DocumentEvent
import java.util.*
import kotlin.io.path.pathString import kotlin.io.path.pathString
@Suppress("PrivatePropertyName") @Suppress("PrivatePropertyName")
@ -55,13 +69,24 @@ class ZLSSettingsPanel(private val project: Project) : ZigProjectConfigurationPr
project, project,
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor() FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor()
.withTitle(ZLSBundle.message("settings.zls-path.browse.title")), .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( private val zlsConfigPath = textFieldWithBrowseButton(
project, project,
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor() FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor()
.withTitle(ZLSBundle.message("settings.zls-config-path.browse.title")) .withTitle(ZLSBundle.message("settings.zls-config-path.browse.title"))
).also { Disposer.register(this, it) } ).also { Disposer.register(this, it) }
private val zlsVersion = JBTextArea().also { it.isEditable = false }
private var debounce: Job? = null
private val inlayHints = JBCheckBox() private val inlayHints = JBCheckBox()
private val enable_snippets = JBCheckBox() private val enable_snippets = JBCheckBox()
private val enable_argument_placeholders = JBCheckBox() private val enable_argument_placeholders = JBCheckBox()
@ -102,6 +127,9 @@ class ZLSSettingsPanel(private val project: Project) : ZigProjectConfigurationPr
cell(direnv) cell(direnv)
} }
} }
row(ZLSBundle.message("settings.zls-version.label")) {
cell(zlsVersion)
}
fancyRow( fancyRow(
"settings.zls-config-path.label", "settings.zls-config-path.label",
"settings.zls-config-path.tooltip" "settings.zls-config-path.tooltip"
@ -250,6 +278,7 @@ class ZLSSettingsPanel(private val project: Project) : ZigProjectConfigurationPr
builtin_path.text = value.builtin_path ?: "" builtin_path.text = value.builtin_path ?: ""
build_runner_path.text = value.build_runner_path ?: "" build_runner_path.text = value.build_runner_path ?: ""
global_cache_path.text = value.global_cache_path ?: "" global_cache_path.text = value.global_cache_path ?: ""
dispatchUpdateUI()
} }
private fun dispatchAutodetect(force: Boolean) { private fun dispatchAutodetect(force: Boolean) {
@ -265,12 +294,14 @@ class ZLSSettingsPanel(private val project: Project) : ZigProjectConfigurationPr
getDirenv().findExecutableOnPATH("zls")?.let { getDirenv().findExecutableOnPATH("zls")?.let {
if (force || zlsPath.text.isBlank()) { if (force || zlsPath.text.isBlank()) {
zlsPath.text = it.pathString zlsPath.text = it.pathString
dispatchUpdateUI()
} }
} }
} }
} }
override fun dispose() { override fun dispose() {
debounce?.cancel("Disposed")
} }
private suspend fun getDirenv(): Env { private suspend fun getDirenv(): Env {
@ -278,6 +309,42 @@ class ZLSSettingsPanel(private val project: Project) : ZigProjectConfigurationPr
return project.getDirenv() return project.getDirenv()
return emptyEnv 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( private fun Panel.fancyRow(

View file

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