From 6f860290eb7909a6c80eb7099f14b62c165c587d Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Fri, 7 Mar 2025 12:58:56 +0200 Subject: [PATCH 1/2] improve config parser and add support for modern lets keywords --- CHANGELOG.md | 12 ++ README.md | 16 ++- .../github/kindermax/intellijlets/Config.kt | 104 ++++++++++++--- .../intellijlets/LetsCompletionProvider.kt | 10 +- .../intellijlets/LetsReferenceContributor.kt | 6 +- .../kindermax/intellijlets/constants.kt | 8 +- .../completion/field/FieldsTest.kt | 4 +- .../intellijlets/config/ConfigTest.kt | 32 +++-- .../intellijlets/reference/ReferenceTest.kt | 122 ++++++++++++++++++ src/test/resources/config/lets.mixin.yaml | 5 + src/test/resources/config/lets.yaml | 21 ++- 11 files changed, 287 insertions(+), 53 deletions(-) create mode 100644 src/test/kotlin/com/github/kindermax/intellijlets/reference/ReferenceTest.kt create mode 100644 src/test/resources/config/lets.mixin.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 37446e8..77673f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ # intellij-lets Changelog ## [Unreleased] +### Updated + +- make go to definition work for gitignore `mixins` files (with '-' (dash) at the beginning) +- support global `init` in config parser and completion +- support `sh` and `checksum` modes in `env` in config parser +- support `ref` and `args` in command keyword completion +- support `mixins` (both local and remote) in config parser +- drop `eval_env` + +### Chore + +- add tests for reference contributor ## [0.0.16] - 2025-03-07 diff --git a/README.md b/README.md index c59d06a..5ff2618 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,18 @@ File type recognition for `lets.yaml` and `lets.*.yaml` configs - **Completion** - Complete keywords - - Complete command `options` with code snippet - - Complete commands in `depends` with code snippet + - [x] Complete command `options` with code snippet + - [x] Complete commands in `depends` with code snippet + - [ ] Complete commands in `depends` from mixins + - [ ] Complete env mode in `env` with code snippet - **Go To Definition** - - Navigate to definitions of `mixins` files + - [x] Navigate to definitions of `mixins` files + - [x] Navigate to definitions of optional `mixins` files (with - at the beginning) + - [x] Navigate to definitions of `mixins` remote files (as http links) + - [ ] Navigate to definitions of commands in `depends` + - [ ] Navigate to definitions of commands in `depends` from mixins +- **Highlighting** + - [ ] Highlighting for shell script in cmd @@ -100,6 +108,4 @@ See https://detekt.github.io/detekt/ Rule Sets to get more info about `detekt` f ## Todo -- add highlighting for shell script in cmd - https://plugins.jetbrains.com/docs/intellij/file-view-providers.html -- read mixins - https://plugins.jetbrains.com/docs/intellij/psi-cookbook.html#how-do-i-find-a-file-if-i-know-its-name-but-don-t-know-the-path - insert not padded strings, but yaml elements \ No newline at end of file diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/Config.kt b/src/main/kotlin/com/github/kindermax/intellijlets/Config.kt index eeef0ba..4e67e7a 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/Config.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/Config.kt @@ -7,14 +7,27 @@ import org.jetbrains.yaml.psi.YAMLMapping import org.jetbrains.yaml.psi.YAMLScalar import org.jetbrains.yaml.psi.YAMLSequence -typealias Env = Map +sealed class EnvValue { + data class StringValue(val value: String) : EnvValue() + data class ShMode(val sh: String) : EnvValue() + data class ChecksumMode(val files: List) : EnvValue() + data class ChecksumMapMode(val files: Map>) : EnvValue() +} + +typealias Env = Map + +sealed class Mixin { + data class Local(val path: String) : Mixin() + data class Remote(val url: String, val version: String) : Mixin() +} + +typealias Mixins = List data class Command( val name: String, val cmd: String, val cmdAsMap: Map, val env: Env, - val evalEnv: Env, val depends: List, ) @@ -34,9 +47,11 @@ class Config( val commands: List, val commandsMap: Map, val env: Env, - val evalEnv: Env, val before: String, - val specifiedDirectives: Set, + val init: String, + val mixins: Mixins, + // Keywords that are used in the config + val keywordsInConfig: Set, ) { companion object Parser { @@ -59,15 +74,44 @@ class Config( emptyList(), emptyMap(), emptyMap(), - emptyMap(), "", + "", + emptyList(), emptySet(), ) } private fun parseEnv(keyValue: YAMLKeyValue): Env { return when (val value = keyValue.value) { - is YAMLMapping -> value.keyValues.associate { kv -> kv.keyText to kv.valueText } + is YAMLMapping -> value.keyValues.associate { + kv -> kv.keyText to when (kv.value) { + is YAMLScalar -> EnvValue.StringValue(kv.valueText) + is YAMLMapping -> { + val kvv = kv.value as YAMLMapping + kvv.getKeyValueByKey("sh")?.let { + EnvValue.ShMode(it.valueText) + } ?: kvv.getKeyValueByKey("checksum")?.let { + when (it.value) { + is YAMLSequence -> { + EnvValue.ChecksumMode((it.value as YAMLSequence).items.mapNotNull { it.value?.text }) + } + + is YAMLMapping -> { + val checksumMap = it.value as YAMLMapping + EnvValue.ChecksumMapMode(checksumMap.keyValues.associate { entry -> + entry.keyText to (entry.value as YAMLSequence).items.mapNotNull { it.value?.text } + }) + } + + else -> { + EnvValue.ChecksumMode(emptyList()) + } + } + } ?: EnvValue.StringValue("") + } + else -> EnvValue.StringValue("") + } + } else -> emptyMap() } } @@ -98,13 +142,19 @@ class Config( } } + private fun parseInit(keyValue: YAMLKeyValue): String { + return when (val value = keyValue.value) { + is YAMLScalar -> value.textValue + else -> "" + } + } + @Suppress("NestedBlockDepth") private fun parseCommand(keyValue: YAMLKeyValue): Command { val name = keyValue.keyText var cmd = "" var cmdAsMap = emptyMap() var env: Env = emptyMap() - var evalEnv: Env = emptyMap() var depends = emptyList() when (val value = keyValue.value) { @@ -129,9 +179,6 @@ class Config( "env" -> { env = parseEnv(kv) } - "eval_env" -> { - evalEnv = parseEnv(kv) - } "depends" -> { depends = parseDepends(kv) } @@ -145,7 +192,6 @@ class Config( cmd, cmdAsMap, env, - evalEnv, depends, ) } @@ -153,12 +199,13 @@ class Config( @Suppress("NestedBlockDepth") private fun parseConfigFromMapping(mapping: YAMLMapping): Config { var shell = "" + val mixins = mutableListOf() val commands = mutableListOf() val commandsMap = mutableMapOf() var env: Env = emptyMap() - var evalEnv: Env = emptyMap() var before = "" - val specifiedDirectives = mutableSetOf() + var init = "" + val keywordsInConfig = mutableSetOf() mapping.keyValues.forEach { kv -> @@ -166,15 +213,33 @@ class Config( "shell" -> { shell = parseShell(kv) } + "mixins" -> { + when (val value = kv.value) { + is YAMLSequence -> { + mixins.addAll( + value.items.mapNotNull { it.value } + .map { when (it) { + is YAMLScalar -> Mixin.Local(it.textValue) + is YAMLMapping -> { + val url = it.getKeyValueByKey("url")?.valueText ?: "" + val version = it.getKeyValueByKey("version")?.valueText ?: "" + Mixin.Remote(url, version) + } + else -> Mixin.Local("") + } } + ) + } + } + } "env" -> { env = parseEnv(kv) } - "eval_env" -> { - evalEnv = parseEnv(kv) - } "before" -> { before = parseBefore(kv) } + "init" -> { + init = parseInit(kv) + } "commands" -> { when (val value = kv.value) { is YAMLMapping -> { @@ -187,7 +252,7 @@ class Config( } } } - specifiedDirectives.add(kv.keyText) + keywordsInConfig.add(kv.keyText) } return Config( @@ -195,9 +260,10 @@ class Config( commands, commandsMap, env, - evalEnv, before, - specifiedDirectives, + init, + mixins, + keywordsInConfig, ) } } diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionProvider.kt b/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionProvider.kt index 832c74d..9170bd1 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionProvider.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionProvider.kt @@ -33,15 +33,15 @@ object LetsCompletionProvider : CompletionProvider() { when { LetsCompletionHelper.isRootLevel(parameters) -> { - val suggestions = when (config.specifiedDirectives.size) { + val suggestions = when (config.keywordsInConfig.size) { 0 -> TOP_LEVEL_KEYWORDS - else -> TOP_LEVEL_KEYWORDS.filterNot { config.specifiedDirectives.contains(it) }.toList() + else -> TOP_LEVEL_KEYWORDS.filterNot { config.keywordsInConfig.contains(it) }.toList() } result.addAllElements( suggestions.map { keyword -> when (keyword) { - "commands", "env", "eval_env" -> createRootKeyNewLineElement(keyword) - "before" -> createRootKeyMultilineElement(keyword) + "commands", "env" -> createRootKeyNewLineElement(keyword) + "before", "init" -> createRootKeyMultilineElement(keyword) "mixins" -> createRootKeyArrayElement(keyword) else -> createRootKeyElement(keyword) } @@ -57,7 +57,7 @@ object LetsCompletionProvider : CompletionProvider() { when (keyword) { "options" -> createOptionsElement() "depends" -> createDependsElement() - "env", "eval_env" -> createCommandKeyNewLineElement(keyword) + "env" -> createCommandKeyNewLineElement(keyword) else -> createCommandKeyElement(keyword) } } diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/LetsReferenceContributor.kt b/src/main/kotlin/com/github/kindermax/intellijlets/LetsReferenceContributor.kt index ba15905..ba60aa5 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/LetsReferenceContributor.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/LetsReferenceContributor.kt @@ -55,7 +55,7 @@ class LetsMixinReference(element: YAMLScalar) : PsiReferenceBase(ele // Collect all YAML files, including in subdirectories val yamlFiles = FilenameIndex.getAllFilesByExt(project, "yaml") - .mapNotNull { file -> file.path?.let { LookupElementBuilder.create(it.removePrefix(project.basePath ?: "")) } } + .mapNotNull { file -> file.path.let { LookupElementBuilder.create(it.removePrefix(project.basePath ?: "")) } } return yamlFiles.toTypedArray() } @@ -65,8 +65,10 @@ class LetsMixinReference(element: YAMLScalar) : PsiReferenceBase(ele * Supports both top-level files ("lets.build.yaml") and nested files ("lets/lets.docs.yaml"). */ private fun findMixinFile(project: Project, mixinPath: String): VirtualFile? { - // Normalize paths (handle both "lets.build.yaml" and "lets/lets.docs.yaml") + // Normalize paths (handle both "lets.mixin.yaml" and "lets/lets.mixin.yaml") val normalizedPath = mixinPath.trimStart('/') + // Normalize gitignored files (e.g. "-lets.mixin.yaml" -> "lets.mixin.yaml") + .removePrefix("-") // Look for an exact match in the project return FilenameIndex.getVirtualFilesByName( diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/constants.kt b/src/main/kotlin/com/github/kindermax/intellijlets/constants.kt index 586122f..baa409c 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/constants.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/constants.kt @@ -14,9 +14,9 @@ val DEFAULT_SHELLS = listOf( val TOP_LEVEL_KEYWORDS = listOf( "shell", "before", + "init", "commands", "env", - "eval_env", "version", "mixins" ) @@ -24,11 +24,13 @@ val TOP_LEVEL_KEYWORDS = listOf( val COMMAND_LEVEL_KEYWORDS = listOf( "description", "env", - "eval_env", "options", "checksum", "persist_checksum", "cmd", + "work_dir", "depends", - "after" + "after", + "ref", + "args", ) diff --git a/src/test/kotlin/com/github/kindermax/intellijlets/completion/field/FieldsTest.kt b/src/test/kotlin/com/github/kindermax/intellijlets/completion/field/FieldsTest.kt index 7464a7a..b4e8a25 100644 --- a/src/test/kotlin/com/github/kindermax/intellijlets/completion/field/FieldsTest.kt +++ b/src/test/kotlin/com/github/kindermax/intellijlets/completion/field/FieldsTest.kt @@ -23,10 +23,10 @@ open class FieldsTest : BasePlatformTestCase() { val expected = listOf( "env", - "eval_env", "version", "mixins", - "before" + "before", + "init" ) assertEquals(expected.sorted(), variants?.sorted()) diff --git a/src/test/kotlin/com/github/kindermax/intellijlets/config/ConfigTest.kt b/src/test/kotlin/com/github/kindermax/intellijlets/config/ConfigTest.kt index 2891a4d..6825611 100644 --- a/src/test/kotlin/com/github/kindermax/intellijlets/config/ConfigTest.kt +++ b/src/test/kotlin/com/github/kindermax/intellijlets/config/ConfigTest.kt @@ -1,9 +1,6 @@ package com.github.kindermax.intellijlets.config -import com.github.kindermax.intellijlets.Command -import com.github.kindermax.intellijlets.CommandParseException -import com.github.kindermax.intellijlets.Config -import com.github.kindermax.intellijlets.ConfigParseException +import com.github.kindermax.intellijlets.* import com.intellij.testFramework.fixtures.BasePlatformTestCase open class ConfigTest : BasePlatformTestCase() { @@ -12,7 +9,7 @@ open class ConfigTest : BasePlatformTestCase() { return "src/test/resources/config" } - fun testParseConfigSuccess() { + fun testParseConfigOk() { val letsFile = myFixture.copyFileToProject("/lets.yaml") myFixture.configureFromExistingVirtualFile(letsFile) val file = myFixture.file @@ -21,8 +18,20 @@ open class ConfigTest : BasePlatformTestCase() { assertEquals(config.shell, "bash") assertEquals(config.before, "echo Before") - assertEquals(config.env, mapOf("DEBUG" to "false")) - assertEquals(config.evalEnv, mapOf("DAY" to "`echo Moday`")) + assertEquals(config.init, "echo Init") + assertEquals( + config.mixins, + listOf( + Mixin.Local("lets.mixin.yaml"), + Mixin.Remote("https://lets-cli.org/mixins/lets.mixin.yaml", "1") + ) + ) + assertEquals(config.env, mapOf( + "DEBUG" to EnvValue.StringValue("false"), + "DAY" to EnvValue.ShMode("`echo Moday`"), + "SELF_CHECKSUM" to EnvValue.ChecksumMode(listOf("lets.yaml")), + "SELF_CHECKSUM_MAP" to EnvValue.ChecksumMapMode(mapOf("self" to listOf("lets.yaml"))), + )) assertEquals( config.commands, listOf( @@ -30,8 +39,10 @@ open class ConfigTest : BasePlatformTestCase() { "run", "echo Run", emptyMap(), - mapOf("DEV" to "true"), - mapOf("UID" to "`echo 1`"), + mapOf( + "DEV" to EnvValue.StringValue("true"), + "UID" to EnvValue.ShMode("`echo 1`") + ), listOf("install") ), Command( @@ -39,7 +50,6 @@ open class ConfigTest : BasePlatformTestCase() { "echo Install", emptyMap(), emptyMap(), - emptyMap(), emptyList() ), Command( @@ -47,7 +57,6 @@ open class ConfigTest : BasePlatformTestCase() { "echo Build", emptyMap(), emptyMap(), - emptyMap(), emptyList() ), Command( @@ -58,7 +67,6 @@ open class ConfigTest : BasePlatformTestCase() { "db" to "echo Db", ), emptyMap(), - emptyMap(), emptyList() ), ) diff --git a/src/test/kotlin/com/github/kindermax/intellijlets/reference/ReferenceTest.kt b/src/test/kotlin/com/github/kindermax/intellijlets/reference/ReferenceTest.kt new file mode 100644 index 0000000..5aaae9a --- /dev/null +++ b/src/test/kotlin/com/github/kindermax/intellijlets/reference/ReferenceTest.kt @@ -0,0 +1,122 @@ +package com.github.kindermax.intellijlets.reference + +import com.intellij.psi.PsiFile +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +open class MinixsReferenceTest : BasePlatformTestCase() { + fun testMixinFileReference() { + myFixture.configureByText( + "lets.yaml", + """ + shell: bash + mixins: + - lets.mixin.yaml + + commands: + run: + cmd: echo Run + """.trimIndent() + ) + + myFixture.configureByText( + "lets.mixin.yaml", + """ + shell: bash + + commands: + test: + cmd: echo Test + """.trimIndent() + ) + + val ref = myFixture.getReferenceAtCaretPosition("lets.yaml") + assertNotNull(ref) + val resolvedFile = ref!!.resolve() as PsiFile + assertNotNull(resolvedFile) + assertEquals("lets.mixin.yaml", resolvedFile.name) + } + + fun testMixinFileInDirReference() { + myFixture.addFileToProject( + "mixins/lets.mixin.yaml", + """ + shell: bash + + commands: + test: + cmd: echo Test + """.trimIndent() + ) + myFixture.configureByText( + "lets.yaml", + """ + shell: bash + mixins: + - mixins/lets.mixin.yaml + + commands: + run: + cmd: echo Run + """.trimIndent() + ) + + val ref = myFixture.getReferenceAtCaretPosition("lets.yaml") + assertNotNull(ref) + val resolvedFile = ref!!.resolve() as PsiFile + assertNotNull(resolvedFile) + assertEquals("lets.mixin.yaml", resolvedFile.name) + } + + // When the mixin file has '-' at the beginning in the `mixins` directive + fun testGitIgnoredMixinFileReference() { + myFixture.addFileToProject( + "lets.mixin.yaml", + """ + shell: bash + + commands: + test: + cmd: echo Test + """.trimIndent() + ) + myFixture.configureByText( + "lets.yaml", + """ + shell: bash + mixins: + - -lets.mixin.yaml + + commands: + run: + cmd: echo Run + """.trimIndent() + ) + + val ref = myFixture.getReferenceAtCaretPosition("lets.yaml") + assertNotNull(ref) + val resolvedFile = ref!!.resolve() as PsiFile + assertNotNull(resolvedFile) + assertEquals("lets.mixin.yaml", resolvedFile.name) + } + + fun testNoMixinFile() { + myFixture.configureByText( + "lets.yaml", + """ + shell: bash + mixins: + - lets.mixin.yaml + + commands: + run: + cmd: echo Run + """.trimIndent() + ) + + val ref = myFixture.getReferenceAtCaretPosition("lets.yaml") + assertNotNull(ref) + val resolvedFile = ref!!.resolve() + assertNull(resolvedFile) + } +} + diff --git a/src/test/resources/config/lets.mixin.yaml b/src/test/resources/config/lets.mixin.yaml new file mode 100644 index 0000000..43fd40b --- /dev/null +++ b/src/test/resources/config/lets.mixin.yaml @@ -0,0 +1,5 @@ +shell: bash + +commands: + test: + cmd: echo Test diff --git a/src/test/resources/config/lets.yaml b/src/test/resources/config/lets.yaml index 8bae486..14bff1e 100644 --- a/src/test/resources/config/lets.yaml +++ b/src/test/resources/config/lets.yaml @@ -1,12 +1,23 @@ shell: bash +mixins: + - lets.mixin.yaml + - url: https://lets-cli.org/mixins/lets.mixin.yaml + version: 1 + env: DEBUG: false - -eval_env: - DAY: `echo Moday` + DAY: + sh: `echo Moday` + SELF_CHECKSUM: + checksum: [lets.yaml] + SELF_CHECKSUM_MAP: + checksum: + self: + - lets.yaml before: echo Before +init: echo Init commands: run: @@ -14,8 +25,8 @@ commands: - install env: DEV: true - eval_env: - UID: `echo 1` + UID: + sh: `echo 1` cmd: echo Run install: From 9a4fe1af5b1d64891699f1e549eddd7100ba6f09 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Fri, 7 Mar 2025 13:10:21 +0200 Subject: [PATCH 2/2] refactor parseEnv to remove nesting --- .../github/kindermax/intellijlets/Config.kt | 53 ++++++++++--------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/Config.kt b/src/main/kotlin/com/github/kindermax/intellijlets/Config.kt index 4e67e7a..df88500 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/Config.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/Config.kt @@ -82,38 +82,41 @@ class Config( } private fun parseEnv(keyValue: YAMLKeyValue): Env { - return when (val value = keyValue.value) { - is YAMLMapping -> value.keyValues.associate { - kv -> kv.keyText to when (kv.value) { - is YAMLScalar -> EnvValue.StringValue(kv.valueText) - is YAMLMapping -> { - val kvv = kv.value as YAMLMapping - kvv.getKeyValueByKey("sh")?.let { - EnvValue.ShMode(it.valueText) - } ?: kvv.getKeyValueByKey("checksum")?.let { - when (it.value) { - is YAMLSequence -> { - EnvValue.ChecksumMode((it.value as YAMLSequence).items.mapNotNull { it.value?.text }) - } + val value = keyValue.value as? YAMLMapping ?: return emptyMap() - is YAMLMapping -> { - val checksumMap = it.value as YAMLMapping - EnvValue.ChecksumMapMode(checksumMap.keyValues.associate { entry -> - entry.keyText to (entry.value as YAMLSequence).items.mapNotNull { it.value?.text } - }) - } + return value.keyValues.associate { kv -> + kv.keyText to parseEnvValue(kv) + } + } - else -> { - EnvValue.ChecksumMode(emptyList()) - } + private fun parseEnvValue(kv: YAMLKeyValue): EnvValue { + return when (val envValue = kv.value) { + is YAMLScalar -> EnvValue.StringValue(envValue.textValue) + is YAMLMapping -> parseMappingEnvValue(envValue) + else -> EnvValue.StringValue("") + } + } + + private fun parseMappingEnvValue(value: YAMLMapping): EnvValue { + value.keyValues.forEach { kv -> + when (kv.keyText) { + "sh" -> return EnvValue.ShMode(kv.valueText) + "checksum" -> { + return when (val checksumValue = kv.value) { + is YAMLSequence -> EnvValue.ChecksumMode( + checksumValue.items.mapNotNull { it.value?.text } + ) + is YAMLMapping -> EnvValue.ChecksumMapMode( + checksumValue.keyValues.associate { entry -> + entry.keyText to (entry.value as YAMLSequence).items.mapNotNull { it.value?.text } } - } ?: EnvValue.StringValue("") + ) + else -> EnvValue.StringValue("") } - else -> EnvValue.StringValue("") } } - else -> emptyMap() } + return EnvValue.StringValue("") } private fun parseShell(keyValue: YAMLKeyValue): String {