From 496c17ead50b9b6a9169c6919e46e9ea5ac35b09 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 8 Mar 2025 19:29:07 +0200 Subject: [PATCH 1/5] Add reference for commands in ref --- .../intellijlets/LetsReferenceContributor.kt | 74 ++++++++++++------- .../intellijlets/reference/ReferenceTest.kt | 43 +++++++++++ 2 files changed, 91 insertions(+), 26 deletions(-) diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/LetsReferenceContributor.kt b/src/main/kotlin/com/github/kindermax/intellijlets/LetsReferenceContributor.kt index 3590e2c..ae3c1f0 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/LetsReferenceContributor.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/LetsReferenceContributor.kt @@ -27,6 +27,7 @@ open class LetsReferenceContributor : PsiReferenceContributor() { return when (yamlKeyValue.keyText) { "mixins" -> arrayOf(LetsMixinReference(element as YAMLScalar)) "depends" -> arrayOf(LetsDependsReference(element as YAMLScalar)) + "ref" -> arrayOf(LetsRefReference(element as YAMLScalar)) else -> emptyArray() } } @@ -35,26 +36,11 @@ open class LetsReferenceContributor : PsiReferenceContributor() { } } -class LetsDependsReference(element: YAMLScalar) : PsiReferenceBase(element) { - override fun resolve(): PsiElement? { - val commandName = myElement.textValue // Extracts the command name inside `depends` - - // Locate the command declaration in the same YAML file - val yamlFile = myElement.containingFile as? YAMLFile ?: return null - val localCommand = PsiTreeUtil.findChildrenOfType(yamlFile, YAMLKeyValue::class.java) - .firstOrNull { it.keyText == commandName && it.parent is YAMLMapping } - - if (localCommand != null) { - return localCommand - } - - // Search for the command in mixin files (with recursive support) - return findCommandInMixins(yamlFile, commandName, mutableSetOf()) - } +open abstract class LetsCommandReference(element: YAMLScalar) : PsiReferenceBase(element) { /** * Recursively searches for the given command name in mixin files. */ - private fun findCommandInMixins(yamlFile: YAMLFile, commandName: String, visitedFiles: MutableSet): PsiElement? { + protected fun findCommandInMixins(yamlFile: YAMLFile, commandName: String, visitedFiles: MutableSet): PsiElement? { if (!visitedFiles.add(yamlFile)) { return null // Prevent infinite recursion } @@ -90,6 +76,51 @@ class LetsDependsReference(element: YAMLScalar) : PsiReferenceBase(e return null } + + /** + * Finds the main lets.yaml configuration file, assuming it is located at the root. + */ + private fun findMainConfigFile(currentFile: YAMLFile): YAMLFile? { + val project = currentFile.project + val mainFiles = FilenameIndex.getVirtualFilesByName("lets.yaml", GlobalSearchScope.projectScope(project)) + return mainFiles.mapNotNull { PsiManager.getInstance(project).findFile(it) as? YAMLFile }.firstOrNull() + } +} + +class LetsDependsReference(element: YAMLScalar) : LetsCommandReference(element) { + override fun resolve(): PsiElement? { + val commandName = myElement.textValue // Extracts the command name inside `depends` + + // Locate the command declaration in the same YAML file + val yamlFile = myElement.containingFile as? YAMLFile ?: return null + val localCommand = PsiTreeUtil.findChildrenOfType(yamlFile, YAMLKeyValue::class.java) + .firstOrNull { it.keyText == commandName && it.parent is YAMLMapping } + + if (localCommand != null) { + return localCommand + } + + // Search for the command in mixin files (with recursive support) + return findCommandInMixins(yamlFile, commandName, mutableSetOf()) + } +} + +class LetsRefReference(element: YAMLScalar) : LetsCommandReference(element) { + override fun resolve(): PsiElement? { + val commandName = myElement.textValue // Extracts the command name inside `ref` + + // Locate the command declaration in the same YAML file + val yamlFile = myElement.containingFile as? YAMLFile ?: return null + val localCommand = PsiTreeUtil.findChildrenOfType(yamlFile, YAMLKeyValue::class.java) + .firstOrNull { it.keyText == commandName && it.parent is YAMLMapping } + + if (localCommand != null) { + return localCommand + } + + // Search for the command in mixin files (with recursive support) + return findCommandInMixins(yamlFile, commandName, mutableSetOf()) + } } class LetsMixinReference(element: YAMLScalar) : PsiReferenceBase(element) { @@ -137,12 +168,3 @@ class LetsMixinReference(element: YAMLScalar) : PsiReferenceBase(ele ).firstOrNull { it.path.endsWith(normalizedPath) } } } - -/** - * Finds the main lets.yaml configuration file, assuming it is located at the root. - */ -private fun findMainConfigFile(currentFile: YAMLFile): YAMLFile? { - val project = currentFile.project - val mainFiles = FilenameIndex.getVirtualFilesByName("lets.yaml", GlobalSearchScope.projectScope(project)) - return mainFiles.mapNotNull { PsiManager.getInstance(project).findFile(it) as? YAMLFile }.firstOrNull() -} \ No newline at end of file diff --git a/src/test/kotlin/com/github/kindermax/intellijlets/reference/ReferenceTest.kt b/src/test/kotlin/com/github/kindermax/intellijlets/reference/ReferenceTest.kt index 082f23e..de9928c 100644 --- a/src/test/kotlin/com/github/kindermax/intellijlets/reference/ReferenceTest.kt +++ b/src/test/kotlin/com/github/kindermax/intellijlets/reference/ReferenceTest.kt @@ -245,3 +245,46 @@ open class DependsReferenceTest : BasePlatformTestCase() { assertEquals("test", resolvedKey!!.keyText) } } + + +open class RefReferenceTest : BasePlatformTestCase() { + fun testRefCommandReference() { + myFixture.addFileToProject( + "mixins/lets.mixin.yaml", + """ + shell: bash + + commands: + build: + cmd: echo Build + """.trimIndent() + ) + + myFixture.configureByText( + "lets.yaml", + """ + shell: bash + mixins: + - mixins/lets.mixin.yaml + + commands: + build-dev: + ref: build + args: --dev + """.trimIndent() + ) + + val ref = myFixture.getReferenceAtCaretPosition("lets.yaml") + assertNotNull("Reference should not be null", ref) + + val resolvedElement = ref!!.resolve() + assertNotNull("Resolved element should not be null", resolvedElement) + + val resolvedFile = resolvedElement?.containingFile + assertEquals("lets.mixin.yaml", resolvedFile?.name) + + val resolvedKey = resolvedElement as? YAMLKeyValue + assertNotNull("Resolved element should be a YAMLKeyValue", resolvedKey) + assertEquals("build", resolvedKey!!.keyText) + } +} From 93c9727bc39867b165b1353d4271cd550e1745f5 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 8 Mar 2025 19:47:38 +0200 Subject: [PATCH 2/5] Add completion for commands in ref --- .../intellijlets/LetsCompletionHelper.kt | 23 ++++++++++++++++++- .../intellijlets/LetsCompletionProvider.kt | 6 +++++ .../kindermax/intellijlets/constants.kt | 1 + 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionHelper.kt b/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionHelper.kt index 483f66f..64951dc 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionHelper.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionHelper.kt @@ -55,6 +55,16 @@ object LetsCompletionHelper { return false } + fun isRefLevel(parameters: CompletionParameters): Boolean { + val yamlKeyValueParents = parameters.position.parentsOfType(false).toList() + + if (yamlKeyValueParents.size == REF_LEVEL) { + return yamlKeyValueParents[0].name == "ref" + } + + return false + } + private fun getDependsParentName(parameters: CompletionParameters): String? { val yamlKeyValueParents = parameters.position.parentsOfType(false).toList() @@ -66,7 +76,7 @@ object LetsCompletionHelper { } /** - * Get all possible depends suggestions for a command, except: + * Get all possible commands suggestions for a `depends`, except: * - itself * - already specified commands in depends * - other commands which depend on current command @@ -92,4 +102,15 @@ object LetsCompletionHelper { .filterNot { command -> excludeList.contains(command) } .toList() } + + /** + * Get all possible commands suggestions for a `ref`, except: + * - itself + * Since ref is a YAMLScalar, only one command is suggested. + */ + fun getRefSuggestions(parameters: CompletionParameters, config: Config): List { + val cmdName = getDependsParentName(parameters) ?: return emptyList() + // Exclude itself from suggestions and return only one suggestion + return config.commandsMap.keys.firstOrNull { it != cmdName }?.let { listOf(it) } ?: emptyList() + } } diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionProvider.kt b/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionProvider.kt index 9170bd1..27d630e 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionProvider.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionProvider.kt @@ -69,6 +69,12 @@ object LetsCompletionProvider : CompletionProvider() { suggestions.map { keyword -> createLookupElement(keyword) } ) } + LetsCompletionHelper.isRefLevel(parameters) -> { + val suggestions = LetsCompletionHelper.getRefSuggestions(parameters, config) + result.addAllElements( + suggestions.map { keyword -> createLookupElement(keyword) } + ) + } } } } diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/constants.kt b/src/main/kotlin/com/github/kindermax/intellijlets/constants.kt index 0e2f571..db7e00c 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/constants.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/constants.kt @@ -1,6 +1,7 @@ package com.github.kindermax.intellijlets const val DEPENDS_LEVEL = 3 +const val REF_LEVEL = 3 const val COMMAND_CHILD_PADDING = 6 const val ROOT_PADDING = 2 From bd0017de2f19f71c7be9266763281530c64304c2 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 8 Mar 2025 21:32:25 +0200 Subject: [PATCH 3/5] Refactor completions in a way to use LetsPsiUtils * LetsPsiUtils is a set of util functions that can replace coslty config parsing * Add tests for LetsPsiUtils --- .../github/kindermax/intellijlets/Config.kt | 52 ++++- .../intellijlets/LetsCompletionHelper.kt | 39 ++-- .../intellijlets/LetsCompletionProvider.kt | 26 +-- .../kindermax/intellijlets/LetsPsiUtils.kt | 78 +++++++ .../intellijlets/LetsReferenceContributor.kt | 11 +- .../intellijlets/LetsPsiUtilsTest.kt | 196 ++++++++++++++++++ .../completion/CompleteKeyword.kt | 38 +++- 7 files changed, 379 insertions(+), 61 deletions(-) create mode 100644 src/main/kotlin/com/github/kindermax/intellijlets/LetsPsiUtils.kt create mode 100644 src/test/kotlin/com/github/kindermax/intellijlets/LetsPsiUtilsTest.kt diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/Config.kt b/src/main/kotlin/com/github/kindermax/intellijlets/Config.kt index df88500..0d49e19 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/Config.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/Config.kt @@ -23,12 +23,19 @@ sealed class Mixin { typealias Mixins = List +//data class Command( +// val name: String, +// val cmd: String, +// val cmdAsMap: Map, +// val env: Env, +// val depends: List, +//) + +// Store original YAMLKeyValue for later use, maybe to lazily parse it data class Command( val name: String, - val cmd: String, - val cmdAsMap: Map, - val env: Env, val depends: List, + val yamlKeyValue: YAMLKeyValue, ) open class ConfigException(message: String) : Exception(message) @@ -36,6 +43,40 @@ open class ConfigException(message: String) : Exception(message) class ConfigParseException(message: String) : ConfigException(message) class CommandParseException(message: String) : ConfigException(message) + +class ConfigParser { + companion object { + fun parseCommand(obj: YAMLKeyValue): Command { + val name = obj.keyText + var depends = emptyList() + when (val value = obj.value) { + is YAMLMapping -> { + value.keyValues.forEach { + kv -> + when (kv.keyText) { + "depends" -> { + depends = parseDepends(kv) + } + } + } + } + } + + return Command( + name, + depends, + obj, + ) + } + + fun parseDepends(obj: YAMLKeyValue): List { + return when (val value = obj.value) { + is YAMLSequence -> value.items.mapNotNull { it.value?.text } + else -> emptyList() + } + } + } +} /** * Representation of current lets.yaml. * Note that since we parse config during completion, the config itself may be broken at that moment, @@ -166,7 +207,6 @@ class Config( kv -> when (kv.keyText) { "cmd" -> { - when (val cmdValue = kv.value) { is YAMLMapping -> { cmdAsMap = cmdValue.keyValues.associate { @@ -192,10 +232,8 @@ class Config( return Command( name, - cmd, - cmdAsMap, - env, depends, + keyValue, ) } diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionHelper.kt b/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionHelper.kt index 64951dc..a0354d4 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionHelper.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionHelper.kt @@ -65,41 +65,33 @@ object LetsCompletionHelper { return false } - private fun getDependsParentName(parameters: CompletionParameters): String? { - val yamlKeyValueParents = parameters.position.parentsOfType(false).toList() - - if (yamlKeyValueParents.size == DEPENDS_LEVEL) { - return yamlKeyValueParents[1].name - } - - return "" - } - /** * Get all possible commands suggestions for a `depends`, except: * - itself * - already specified commands in depends * - other commands which depend on current command */ - fun getDependsSuggestions(parameters: CompletionParameters, config: Config): List { - val cmdName = getDependsParentName(parameters) ?: return emptyList() - val cmd = config.commandsMap[cmdName] ?: return emptyList() + fun getDependsSuggestions(parameters: CompletionParameters): List { + val yamlFile = parameters.originalFile as? YAMLFile ?: return emptyList() + val allCommands = LetsPsiUtils.findAllCommands(yamlFile) + val currentCommand = LetsPsiUtils.findCurrentCommand(parameters.position) ?: return emptyList() val excludeList = mutableSetOf() // exclude itself - excludeList.add(cmdName) + excludeList.add(currentCommand.name) // exclude commands already in depends list - excludeList.addAll(cmd.depends) + excludeList.addAll(currentCommand.depends) // exclude commands which depends on current command (eliminate recursive dependencies) - for (command in config.commands.filter { c -> c.name != cmdName }) { - if (command.depends.contains(cmdName)) { + for (command in allCommands.filter { c -> c.name != currentCommand.name }) { + if (command.depends.contains(currentCommand.name)) { excludeList.add(command.name) } } - return config.commandsMap.keys - .filterNot { command -> excludeList.contains(command) } + return allCommands + .filterNot { command -> excludeList.contains(command.name) } + .map { it.name } .toList() } @@ -108,9 +100,12 @@ object LetsCompletionHelper { * - itself * Since ref is a YAMLScalar, only one command is suggested. */ - fun getRefSuggestions(parameters: CompletionParameters, config: Config): List { - val cmdName = getDependsParentName(parameters) ?: return emptyList() + fun getRefSuggestions(parameters: CompletionParameters): List { + val yamlFile = parameters.originalFile as? YAMLFile ?: return emptyList() + val allCommands = LetsPsiUtils.findAllCommands(yamlFile) + val currentCommand = LetsPsiUtils.findCurrentCommand(parameters.position) ?: return emptyList() // Exclude itself from suggestions and return only one suggestion - return config.commandsMap.keys.firstOrNull { it != cmdName }?.let { listOf(it) } ?: emptyList() + return allCommands.filterNot { it.name == currentCommand.name } + .map { it.name } } } diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionProvider.kt b/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionProvider.kt index 27d630e..cdb6298 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionProvider.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionProvider.kt @@ -7,35 +7,23 @@ import com.intellij.codeInsight.completion.InsertHandler import com.intellij.codeInsight.completion.InsertionContext import com.intellij.codeInsight.lookup.LookupElement import com.intellij.codeInsight.lookup.LookupElementBuilder -import com.intellij.psi.PsiFile import com.intellij.util.ProcessingContext -import java.util.logging.Logger +import org.jetbrains.yaml.psi.YAMLFile object LetsCompletionProvider : CompletionProvider() { - private val log: Logger = Logger.getLogger(LetsCompletionProvider.javaClass.name) - - private fun maybeGetConfig(file: PsiFile): Config? { - return try { - Config.parseFromPSI(file) - } catch (exp: ConfigException) { - log.warning(exp.toString()) - return null - } - } - @Suppress("ComplexMethod") override fun addCompletions( parameters: CompletionParameters, context: ProcessingContext, result: CompletionResultSet, ) { - val config = maybeGetConfig(parameters.originalFile) ?: return - when { LetsCompletionHelper.isRootLevel(parameters) -> { - val suggestions = when (config.keywordsInConfig.size) { + val yamlFile = parameters.originalFile as YAMLFile + val usedKeywords = LetsPsiUtils.getUsedKeywords(yamlFile) + val suggestions = when (usedKeywords.size) { 0 -> TOP_LEVEL_KEYWORDS - else -> TOP_LEVEL_KEYWORDS.filterNot { config.keywordsInConfig.contains(it) }.toList() + else -> TOP_LEVEL_KEYWORDS.filterNot { usedKeywords.contains(it) }.toList() } result.addAllElements( suggestions.map { keyword -> @@ -64,13 +52,13 @@ object LetsCompletionProvider : CompletionProvider() { ) } LetsCompletionHelper.isDependsLevel(parameters) -> { - val suggestions = LetsCompletionHelper.getDependsSuggestions(parameters, config) + val suggestions = LetsCompletionHelper.getDependsSuggestions(parameters) result.addAllElements( suggestions.map { keyword -> createLookupElement(keyword) } ) } LetsCompletionHelper.isRefLevel(parameters) -> { - val suggestions = LetsCompletionHelper.getRefSuggestions(parameters, config) + val suggestions = LetsCompletionHelper.getRefSuggestions(parameters) result.addAllElements( suggestions.map { keyword -> createLookupElement(keyword) } ) diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/LetsPsiUtils.kt b/src/main/kotlin/com/github/kindermax/intellijlets/LetsPsiUtils.kt new file mode 100644 index 0000000..b60ef28 --- /dev/null +++ b/src/main/kotlin/com/github/kindermax/intellijlets/LetsPsiUtils.kt @@ -0,0 +1,78 @@ +package com.github.kindermax.intellijlets + +import com.intellij.psi.PsiElement +import com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.yaml.psi.* + +object LetsPsiUtils { + fun findCommandsInFile(yamlFile: YAMLFile): List { + val commandsKV = PsiTreeUtil.findChildrenOfType(yamlFile, YAMLKeyValue::class.java) + .firstOrNull { it.keyText == "commands" } ?: return emptyList() + + return PsiTreeUtil.findChildrenOfType(commandsKV, YAMLKeyValue::class.java) + .filter { it.parent.parent == commandsKV } + .mapNotNull { keyValue -> ConfigParser.parseCommand(keyValue) } + } + + fun findCommandsInMixins(yamlFile: YAMLFile): List { + val mixinFiles = findMixinFiles(yamlFile) + return mixinFiles.flatMap { findCommandsInFile(it) } + } + + /** + * Find all commands in the given YAML file and its mixins. + * If a command is defined in both the file and its mixins, commands are merged. + * Merge rules are: ??? + */ + fun findAllCommands(yamlFile: YAMLFile): List { + val localCommands = findCommandsInFile(yamlFile) + val mixinCommands = findCommandsInMixins(yamlFile) + // TODO: implement merge rules + return localCommands + mixinCommands + } + + /** + * Find all mixin files referenced in the given YAML file. + * It searches for the "mixins" key in the root of the file, + * and returns the resolved YAML files (files must exist to end up in result). + */ + fun findMixinFiles(yamlFile: YAMLFile): List { + val mixinsKey = PsiTreeUtil.findChildrenOfType(yamlFile, YAMLKeyValue::class.java) + .firstOrNull { it.keyText == "mixins" } ?: return emptyList() + + return mixinsKey.value?.children + ?.mapNotNull { it as? YAMLSequenceItem } + ?.mapNotNull { it.value as? YAMLScalar } + ?.mapNotNull { LetsMixinReference(it).resolve() as? YAMLFile } + ?: emptyList() + } + + /** + * Find the command that the given position is in. + * If position is inside a command, return the command, otherwise return null. + */ + fun findCurrentCommand( + position: PsiElement + ): Command? { + val currentKeyValue = PsiTreeUtil.getParentOfType(position, YAMLKeyValue::class.java) ?: return null + if (!COMMAND_LEVEL_KEYWORDS.contains(currentKeyValue.keyText)) { + return null + } + val parentCommand = currentKeyValue.parent.parent as? YAMLKeyValue ?: return null + return ConfigParser.parseCommand(parentCommand) + } + + fun getUsedKeywords(yamlFile: YAMLFile): List { + val usedKeywords = mutableListOf() + + val rootMapping = yamlFile.documents.firstOrNull()?.topLevelValue as? YAMLMapping ?: return emptyList() + + for (keyValue in rootMapping.keyValues) { + if (keyValue.keyText in TOP_LEVEL_KEYWORDS) { + usedKeywords.add(keyValue.keyText) + } + } + + return usedKeywords + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/LetsReferenceContributor.kt b/src/main/kotlin/com/github/kindermax/intellijlets/LetsReferenceContributor.kt index ae3c1f0..8183791 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/LetsReferenceContributor.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/LetsReferenceContributor.kt @@ -14,7 +14,6 @@ import org.jetbrains.yaml.psi.YAMLFile import org.jetbrains.yaml.psi.YAMLKeyValue import org.jetbrains.yaml.psi.YAMLMapping import org.jetbrains.yaml.psi.YAMLScalar -import org.jetbrains.yaml.psi.YAMLSequenceItem open class LetsReferenceContributor : PsiReferenceContributor() { override fun registerReferenceProviders(registrar: PsiReferenceRegistrar) { @@ -48,15 +47,7 @@ open abstract class LetsCommandReference(element: YAMLScalar) : PsiReferenceBas // If the current file is a mixin, retrieve the main lets.yaml file val mainConfigFile = findMainConfigFile(yamlFile) ?: return null - // Find the mixins key in the main lets.yaml file - val mixinsKey = PsiTreeUtil.findChildrenOfType(mainConfigFile, YAMLKeyValue::class.java) - .firstOrNull { it.keyText == "mixins" } ?: return null - - // Extract mixin file names from YAMLSequenceItems - val mixinFiles = mixinsKey.value?.children - ?.mapNotNull { it as? YAMLSequenceItem } - ?.mapNotNull { it.value as? YAMLScalar } - ?.mapNotNull { LetsMixinReference(it).resolve() as? YAMLFile } ?: return null + val mixinFiles = LetsPsiUtils.findMixinFiles(mainConfigFile) ?: return null // Search for the command in the resolved mixin files for (mixinFile in mixinFiles) { diff --git a/src/test/kotlin/com/github/kindermax/intellijlets/LetsPsiUtilsTest.kt b/src/test/kotlin/com/github/kindermax/intellijlets/LetsPsiUtilsTest.kt new file mode 100644 index 0000000..67d0f4f --- /dev/null +++ b/src/test/kotlin/com/github/kindermax/intellijlets/LetsPsiUtilsTest.kt @@ -0,0 +1,196 @@ +package com.github.kindermax.intellijlets + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import org.jetbrains.yaml.psi.YAMLFile + +class LetsPsiUtilsTest : BasePlatformTestCase() { + fun testFindCommandsInFile() { + val file = myFixture.configureByText( + "lets.yaml", + """ + shell: bash + commands: + hello: + cmd: echo Hello + depends: [world] + hi: + cmd: echo Hi + lol: + cmd: echo Lol + world: + depends: [] + cmd: echo World + """.trimIndent() + ) + val commands = LetsPsiUtils.findCommandsInFile(file as YAMLFile) + + val expected = listOf("hello", "hi", "lol", "world") + + assertEquals(expected.sorted(), commands.map { it.name }.sorted()) + } + + fun testCommandsInMixins() { + val file = myFixture.configureByText( + "lets.yaml", + """ + shell: bash + mixins: [lets.mixin1.yaml, lets.mixin2.yaml] + commands: + world: + cmd: echo World + """.trimIndent() + ) + + myFixture.configureByText( + "lets.mixin1.yaml", + """ + shell: bash + commands: + hello: + cmd: echo Hello + """.trimIndent() + ) + + myFixture.configureByText( + "lets.mixin2.yaml", + """ + shell: bash + commands: + bye: + cmd: echo Bye + """.trimIndent() + ) + val commands = LetsPsiUtils.findCommandsInMixins(file as YAMLFile) + + val expected = listOf("hello", "bye") + + assertEquals(expected.sorted(), commands.map { it.name }.sorted()) + } + + fun testFindAllCommands() { + val file = myFixture.configureByText( + "lets.yaml", + """ + shell: bash + mixins: [lets.mixin.yaml] + commands: + world: + cmd: echo World + """.trimIndent() + ) + + myFixture.configureByText( + "lets.mixin.yaml", + """ + shell: bash + commands: + hello: + cmd: echo Hello + """.trimIndent() + ) + val commands = LetsPsiUtils.findAllCommands(file as YAMLFile) + + val expected = listOf("hello", "world") + + assertEquals(expected.sorted(), commands.map { it.name }.sorted()) + } + + fun testFindMixinFiles() { + val file = myFixture.configureByText( + "lets.yaml", + """ + shell: bash + mixins: [lets.mixin1.yaml, lets.mixin2.yaml] + commands: + world: + cmd: echo World + """.trimIndent() + ) + + myFixture.configureByText( + "lets.mixin1.yaml", + """ + shell: bash + commands: + hello: + cmd: echo Hello + """.trimIndent() + ) + + myFixture.configureByText( + "lets.mixin2.yaml", + """ + shell: bash + commands: + bye: + cmd: echo Bye + """.trimIndent() + ) + val mixins = LetsPsiUtils.findMixinFiles(file as YAMLFile) + + val expected = listOf("lets.mixin1.yaml", "lets.mixin2.yaml") + + assertEquals(expected.sorted(), mixins.map { it.name }.sorted()) + } + + fun testFindCurrentCommand() { + val file = myFixture.configureByText( + "lets.yaml", + """ + shell: bash + commands: + world: + cmd: echo World + depends: [hello] + """.trimIndent() + ) + + val element = file.findElementAt(myFixture.caretOffset)!! + var command = LetsPsiUtils.findCurrentCommand(element) + assertNotNull(command) + command = command!! + + assertEquals(command.name, "world") + assertEquals(command.depends.sorted(), listOf("hello").sorted()) + } + + fun testFindCurrentCommandInDepends() { + val file = myFixture.configureByText( + "lets.yaml", + """ + shell: bash + commands: + world: + cmd: echo World + depends: [hello, ] + """.trimIndent() + ) + + val element = file.findElementAt(myFixture.caretOffset)!! + var command = LetsPsiUtils.findCurrentCommand(element) + assertNotNull(command) + command = command!! + + assertEquals(command.name, "world") + assertEquals(command.depends.sorted(), listOf("hello").sorted()) + } + + fun testUsedKeywords() { + val file = myFixture.configureByText( + "lets.yaml", + """ + shell: bash + mixins: [lets.mixin1.yaml] + before: echo Before + commands: + world: + cmd: echo World + env: + KEY: VALUE + """.trimIndent() + ) + + val usedKeywords = LetsPsiUtils.getUsedKeywords(file as YAMLFile) + assertEquals(usedKeywords.sorted(), listOf("shell", "mixins", "before", "commands").sorted()) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteKeyword.kt b/src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteKeyword.kt index b51ce5e..f3e3bb6 100644 --- a/src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteKeyword.kt +++ b/src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteKeyword.kt @@ -143,9 +143,7 @@ open class CompleteKeywordTest : BasePlatformTestCase() { val variants = myFixture.getCompletionVariants("lets.yaml") assertNotNull(variants) - val expected = listOf( - "hi", "lol" - ) + val expected = listOf("hi", "lol") assertEquals(expected.sorted(), variants?.sorted()) } @@ -195,4 +193,38 @@ open class CompleteKeywordTest : BasePlatformTestCase() { myFixture.file.text, ) } + + fun testRefCompletionWorks() { + myFixture.addFileToProject( + "mixins/lets.mixin.yaml", + """ + shell: bash + + commands: + build: + cmd: echo Build + """.trimIndent() + ) + + myFixture.configureByText( + "lets.yaml", + """ + shell: bash + mixins: + - mixins/lets.mixin.yaml + + commands: + build-dev: + ref: + args: --dev + """.trimIndent() + ) + + val variants = myFixture.getCompletionVariants("lets.yaml") + assertNotNull(variants) + + val expected = listOf("build") + + assertEquals(expected.sorted(), variants?.sorted()) + } } From d7438c697a4ca839d0b11e4f73eaa9ac50431bf8 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 8 Mar 2025 22:21:45 +0200 Subject: [PATCH 4/5] Refactor Config,kt * Move all parse* methods to ConfigParser * Rewrite all tests for ConfigParser * Remove config test resources --- README.md | 4 +- .../github/kindermax/intellijlets/Config.kt | 237 ++++-------------- .../intellijlets/LetsCompletionHelper.kt | 1 + .../completion/CompleteKeyword.kt | 48 +++- .../intellijlets/config/ConfigTest.kt | 198 +++++++++++---- .../intellijlets/reference/ReferenceTest.kt | 1 - src/test/resources/config/lets.mixin.yaml | 5 - src/test/resources/config/lets.yaml | 42 ---- 8 files changed, 242 insertions(+), 294 deletions(-) delete mode 100644 src/test/resources/config/lets.mixin.yaml delete mode 100644 src/test/resources/config/lets.yaml diff --git a/README.md b/README.md index 4523685..aff2b7d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ File type recognition for `lets.yaml` and `lets.*.yaml` configs - **Completion** - [x] Complete command `options` with code snippet - [x] Complete commands in `depends` with code snippet - - [ ] Complete commands in `depends` from mixins + - [x] Complete commands in `depends` from mixins - [ ] Complete env mode in `env` with code snippet - [x] Complete `LETS*` environment variables in cmd scripts - [ ] Complete environment variables for checksum @@ -26,7 +26,7 @@ File type recognition for `lets.yaml` and `lets.*.yaml` configs - [x] Navigate to definitions of `mixins` remote files (as http links) - [x] Navigate to definitions of commands in `depends` - [x] Navigate to definitions of commands in `depends` from mixins - - [ ] Navigate to definitions of commands in `ref` + - [x] Navigate to definitions of commands in `ref` - [ ] Navigate to files in `checksum` - **Highlighting** - [x] Highlighting for shell script in `cmd` diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/Config.kt b/src/main/kotlin/com/github/kindermax/intellijlets/Config.kt index 0d49e19..e3c8a97 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/Config.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/Config.kt @@ -1,7 +1,5 @@ package com.github.kindermax.intellijlets -import com.intellij.psi.PsiFile -import org.jetbrains.yaml.psi.YAMLDocument import org.jetbrains.yaml.psi.YAMLKeyValue import org.jetbrains.yaml.psi.YAMLMapping import org.jetbrains.yaml.psi.YAMLScalar @@ -23,32 +21,26 @@ sealed class Mixin { typealias Mixins = List -//data class Command( -// val name: String, -// val cmd: String, -// val cmdAsMap: Map, -// val env: Env, -// val depends: List, -//) - -// Store original YAMLKeyValue for later use, maybe to lazily parse it data class Command( val name: String, + val cmd: String, + val cmdAsMap: Map, + val env: Env, val depends: List, - val yamlKeyValue: YAMLKeyValue, + val yaml: YAMLKeyValue, ) -open class ConfigException(message: String) : Exception(message) - -class ConfigParseException(message: String) : ConfigException(message) -class CommandParseException(message: String) : ConfigException(message) - - class ConfigParser { companion object { +// @Suppress("NestedBlockDepth") fun parseCommand(obj: YAMLKeyValue): Command { val name = obj.keyText var depends = emptyList() + + var cmd = "" + var cmdAsMap = emptyMap() + var env: Env = emptyMap() + when (val value = obj.value) { is YAMLMapping -> { value.keyValues.forEach { @@ -57,6 +49,22 @@ class ConfigParser { "depends" -> { depends = parseDepends(kv) } + "cmd" -> { + when (val cmdValue = kv.value) { + is YAMLMapping -> { + cmdAsMap = cmdValue.keyValues.associate { + cmdEntry -> + cmdEntry.keyText to cmdEntry.valueText + } + } + else -> { + cmd = parseCmd(kv) + } + } + } + "env" -> { + env = parseEnv(kv) + } } } } @@ -64,6 +72,9 @@ class ConfigParser { return Command( name, + cmd, + cmdAsMap, + env, depends, obj, ) @@ -74,55 +85,9 @@ class ConfigParser { is YAMLSequence -> value.items.mapNotNull { it.value?.text } else -> emptyList() } - } - } -} -/** - * Representation of current lets.yaml. - * Note that since we parse config during completion, the config itself may be broken at that moment, - * so we should parse gracefully. - */ -@Suppress("LongParameterList") -class Config( - val shell: String, - val commands: List, - val commandsMap: Map, - val env: Env, - val before: String, - val init: String, - val mixins: Mixins, - // Keywords that are used in the config - val keywordsInConfig: Set, -) { - - companion object Parser { - // TODO parse mixins - fun parseFromPSI(file: PsiFile): Config { - return when (val child = file.firstChild) { - is YAMLDocument -> { - when (val value = child.topLevelValue) { - is YAMLMapping -> parseConfigFromMapping(value) - else -> defaultConfig() - } - } - else -> defaultConfig() - } - } - - private fun defaultConfig(): Config { - return Config( - "", - emptyList(), - emptyMap(), - emptyMap(), - "", - "", - emptyList(), - emptySet(), - ) - } + } - private fun parseEnv(keyValue: YAMLKeyValue): Env { + fun parseEnv(keyValue: YAMLKeyValue): Env { val value = keyValue.value as? YAMLMapping ?: return emptyMap() return value.keyValues.associate { kv -> @@ -130,7 +95,7 @@ class Config( } } - private fun parseEnvValue(kv: YAMLKeyValue): EnvValue { + fun parseEnvValue(kv: YAMLKeyValue): EnvValue { return when (val envValue = kv.value) { is YAMLScalar -> EnvValue.StringValue(envValue.textValue) is YAMLMapping -> parseMappingEnvValue(envValue) @@ -138,7 +103,7 @@ class Config( } } - private fun parseMappingEnvValue(value: YAMLMapping): EnvValue { + fun parseMappingEnvValue(value: YAMLMapping): EnvValue { value.keyValues.forEach { kv -> when (kv.keyText) { "sh" -> return EnvValue.ShMode(kv.valueText) @@ -160,11 +125,11 @@ class Config( return EnvValue.StringValue("") } - private fun parseShell(keyValue: YAMLKeyValue): String { + fun parseShell(keyValue: YAMLKeyValue): String { return keyValue.valueText } - private fun parseCmd(keyValue: YAMLKeyValue): String { + fun parseCmd(keyValue: YAMLKeyValue): String { return when (val value = keyValue.value) { is YAMLScalar -> value.text is YAMLSequence -> value.items.mapNotNull { it.value?.text }.joinToString(" ") @@ -172,140 +137,36 @@ class Config( } } - private fun parseDepends(keyValue: YAMLKeyValue): List { - return when (val value = keyValue.value) { - is YAMLSequence -> value.items.mapNotNull { it.value?.text } - else -> emptyList() - } - } - - private fun parseBefore(keyValue: YAMLKeyValue): String { + fun parseBefore(keyValue: YAMLKeyValue): String { return when (val value = keyValue.value) { is YAMLScalar -> value.textValue else -> "" } } - private fun parseInit(keyValue: YAMLKeyValue): String { + 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 depends = emptyList() - - when (val value = keyValue.value) { - is YAMLMapping -> { - value.keyValues.forEach { - kv -> - when (kv.keyText) { - "cmd" -> { - when (val cmdValue = kv.value) { - is YAMLMapping -> { - cmdAsMap = cmdValue.keyValues.associate { - cmdEntry -> - cmdEntry.keyText to cmdEntry.valueText - } - } - else -> { - cmd = parseCmd(kv) - } - } - } - "env" -> { - env = parseEnv(kv) - } - "depends" -> { - depends = parseDepends(kv) - } - } - } - } - } - - return Command( - name, - depends, - keyValue, - ) - } - - @Suppress("NestedBlockDepth") - private fun parseConfigFromMapping(mapping: YAMLMapping): Config { - var shell = "" - val mixins = mutableListOf() - val commands = mutableListOf() - val commandsMap = mutableMapOf() - var env: Env = emptyMap() - var before = "" - var init = "" - val keywordsInConfig = mutableSetOf() - - mapping.keyValues.forEach { - kv -> - when (kv.keyText) { - "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) - } - "before" -> { - before = parseBefore(kv) - } - "init" -> { - init = parseInit(kv) - } - "commands" -> { - when (val value = kv.value) { + fun parseMixins(keyValue: YAMLKeyValue): Mixins { + return when (val value = keyValue.value) { + is YAMLSequence -> { + value.items.mapNotNull { it.value } + .map { when (it) { + is YAMLScalar -> Mixin.Local(it.textValue) is YAMLMapping -> { - value.keyValues.forEach { rawCommand -> - val command = parseCommand(rawCommand) - commands.add(command) - commandsMap[command.name] = command - } + val url = it.getKeyValueByKey("url")?.valueText ?: "" + val version = it.getKeyValueByKey("version")?.valueText ?: "" + Mixin.Remote(url, version) } - } - } + else -> Mixin.Local("") + } } } - keywordsInConfig.add(kv.keyText) + else -> emptyList() } - - return Config( - shell, - commands, - commandsMap, - env, - before, - init, - mixins, - keywordsInConfig, - ) } } } diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionHelper.kt b/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionHelper.kt index a0354d4..05e0921 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionHelper.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionHelper.kt @@ -45,6 +45,7 @@ object LetsCompletionHelper { ) } + // TODO: refactor this methods, find more idiomatic way to check the level fun isDependsLevel(parameters: CompletionParameters): Boolean { val yamlKeyValueParents = parameters.position.parentsOfType(false).toList() diff --git a/src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteKeyword.kt b/src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteKeyword.kt index f3e3bb6..c25d649 100644 --- a/src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteKeyword.kt +++ b/src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteKeyword.kt @@ -4,8 +4,7 @@ import com.github.kindermax.intellijlets.DEFAULT_SHELLS import com.intellij.testFramework.fixtures.BasePlatformTestCase open class CompleteKeywordTest : BasePlatformTestCase() { - - fun testRootCompletion() { + fun testRootKeywordCompletion() { myFixture.configureByText( "lets.yaml", """ @@ -44,7 +43,7 @@ open class CompleteKeywordTest : BasePlatformTestCase() { assertEquals(DEFAULT_SHELLS.sorted(), variants?.sorted()) } - fun testCommandCompletionWithCLetter() { + fun testCommandLevelKeywordCompletion() { myFixture.configureByText( "lets.yaml", """ @@ -121,7 +120,9 @@ open class CompleteKeywordTest : BasePlatformTestCase() { myFixture.file.text.trimIndent(), ) } +} +open class CompleteCommandsInDependsTest : BasePlatformTestCase() { fun testDependsCompletionWorks() { myFixture.configureByText( "lets.yaml", @@ -194,7 +195,46 @@ open class CompleteKeywordTest : BasePlatformTestCase() { ) } - fun testRefCompletionWorks() { + fun testCompleteCommandFromMixinsAsWell() { + myFixture.addFileToProject( + "mixins/lets.mixin.yaml", + """ + shell: bash + + commands: + build: + cmd: echo Build + """.trimIndent() + ) + + myFixture.configureByText( + "lets.yaml", + """ + shell: bash + mixins: + - mixins/lets.mixin.yaml + + commands: + test: + cmd: echo Test + + run: + depends: [] + cmd: echo Run + """.trimIndent() + ) + + val variants = myFixture.getCompletionVariants("lets.yaml") + assertNotNull(variants) + + val expected = setOf("test", "build") + + assertEquals(expected, variants?.toSet()) + } +} + +open class CompleteCommandsInRefTest : BasePlatformTestCase() { + fun testRefCompletionWorks() { myFixture.addFileToProject( "mixins/lets.mixin.yaml", """ 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 6825611..6137b55 100644 --- a/src/test/kotlin/com/github/kindermax/intellijlets/config/ConfigTest.kt +++ b/src/test/kotlin/com/github/kindermax/intellijlets/config/ConfigTest.kt @@ -1,39 +1,54 @@ package com.github.kindermax.intellijlets.config import com.github.kindermax.intellijlets.* +import com.intellij.psi.util.PsiTreeUtil import com.intellij.testFramework.fixtures.BasePlatformTestCase +import org.jetbrains.yaml.psi.YAMLFile +import org.jetbrains.yaml.psi.YAMLKeyValue -open class ConfigTest : BasePlatformTestCase() { +open class ConfigParserTest : BasePlatformTestCase() { + fun testParseCommand() { + val file = myFixture.configureByText( + "lets.yaml", + """ + shell: bash + + commands: + run: + depends: + - install + env: + DEV: true + UID: + sh: `echo 1` + cmd: echo Run - override fun getTestDataPath(): String { - return "src/test/resources/config" - } + install: + cmd: echo Install - fun testParseConfigOk() { - val letsFile = myFixture.copyFileToProject("/lets.yaml") - myFixture.configureFromExistingVirtualFile(letsFile) - val file = myFixture.file + build: + cmd: + - echo + - Build + dev: + cmd: + app: echo App + db: echo Db + """.trimIndent() + ) as YAMLFile - val config = Config.parseFromPSI(file) + val commandsKV = PsiTreeUtil.findChildrenOfType(file, YAMLKeyValue::class.java) + .firstOrNull { it.keyText == "commands" }!! + + val commands = PsiTreeUtil.findChildrenOfType(commandsKV, YAMLKeyValue::class.java) + .filter { it.parent.parent == commandsKV } + .map { ConfigParser.parseCommand(it) } + + val elements = PsiTreeUtil.findChildrenOfType(commandsKV, YAMLKeyValue::class.java) + .filter { it.parent.parent == commandsKV } - assertEquals(config.shell, "bash") - assertEquals(config.before, "echo Before") - 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, + commands, listOf( Command( "run", @@ -43,21 +58,24 @@ open class ConfigTest : BasePlatformTestCase() { "DEV" to EnvValue.StringValue("true"), "UID" to EnvValue.ShMode("`echo 1`") ), - listOf("install") + listOf("install"), + elements[0] ), Command( "install", "echo Install", emptyMap(), emptyMap(), - emptyList() + emptyList(), + elements[1] ), Command( "build", "echo Build", emptyMap(), emptyMap(), - emptyList() + emptyList(), + elements[2], ), Command( "dev", @@ -67,46 +85,122 @@ open class ConfigTest : BasePlatformTestCase() { "db" to "echo Db", ), emptyMap(), - emptyList() + emptyList(), + elements[3], ), ) ) } - fun testParseBrokenConfig() { - myFixture.configureByText( + fun testParseEnv() { + val file = myFixture.configureByText( "lets.yaml", """ - - aaa - - bb + shell: bash + env: + DEBUG: false + DAY: + sh: `echo Moday` + SELF_CHECKSUM: + checksum: [lets.yaml] + SELF_CHECKSUM_MAP: + checksum: + self: + - lets.yaml + commands: + build: + cmd: echo Build """.trimIndent() - ) - val file = myFixture.file + ) as YAMLFile + + val envKV = PsiTreeUtil.findChildrenOfType(file, YAMLKeyValue::class.java) + .firstOrNull { it.keyText == "env" }!! - try { - Config.parseFromPSI(file) - } catch (exc: ConfigParseException) { - assertEquals(exc.message, "failed to parse config: not a valid document") - } + val env = ConfigParser.parseEnv(envKV) + + assertEquals(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"))), + )) } - fun testParseBrokenCommand() { - myFixture.configureByText( + fun testParseMixins() { + val file = myFixture.configureByText( "lets.yaml", """ shell: bash + mixins: + - lets.mixin.yaml + - url: https://lets-cli.org/mixins/lets.mixin.yaml + version: 1 commands: - run: - - foo - - bar + build: + cmd: echo Build """.trimIndent() + ) as YAMLFile + + val mixinsKV = PsiTreeUtil.findChildrenOfType(file, YAMLKeyValue::class.java) + .firstOrNull { it.keyText == "mixins" }!! + + val mixins = ConfigParser.parseMixins(mixinsKV) + + assertEquals( + mixins, + listOf( + Mixin.Local("lets.mixin.yaml"), + Mixin.Remote("https://lets-cli.org/mixins/lets.mixin.yaml", "1") + ) ) - val file = myFixture.file + } + + fun testParseShell() { + val file = myFixture.configureByText( + "lets.yaml", + """ + shell: bash + """.trimIndent() + ) as YAMLFile + + val shellKV = PsiTreeUtil.findChildrenOfType(file, YAMLKeyValue::class.java) + .firstOrNull { it.keyText == "shell" }!! + + val shell = ConfigParser.parseShell(shellKV) + assertEquals(shell, "bash") + } + + fun testParseBefore() { + val file = myFixture.configureByText( + "lets.yaml", + """ + shell: bash + before: echo Before + init: echo Init + """.trimIndent() + ) as YAMLFile + + val beforeKV = PsiTreeUtil.findChildrenOfType(file, YAMLKeyValue::class.java) + .firstOrNull { it.keyText == "before" }!! + + val before = ConfigParser.parseBefore(beforeKV) + assertEquals(before, "echo Before") + } + + fun testParseInit() { + val file = myFixture.configureByText( + "lets.yaml", + """ + shell: bash + before: echo Before + init: echo Init + """.trimIndent() + ) as YAMLFile + + val initKV = PsiTreeUtil.findChildrenOfType(file, YAMLKeyValue::class.java) + .firstOrNull { it.keyText == "init" }!! - try { - Config.parseFromPSI(file) - } catch (exc: CommandParseException) { - assertEquals(exc.message, "failed to parse command run") - } + val init = ConfigParser.parseBefore(initKV) + assertEquals(init, "echo Init") } } diff --git a/src/test/kotlin/com/github/kindermax/intellijlets/reference/ReferenceTest.kt b/src/test/kotlin/com/github/kindermax/intellijlets/reference/ReferenceTest.kt index de9928c..40eaf47 100644 --- a/src/test/kotlin/com/github/kindermax/intellijlets/reference/ReferenceTest.kt +++ b/src/test/kotlin/com/github/kindermax/intellijlets/reference/ReferenceTest.kt @@ -246,7 +246,6 @@ open class DependsReferenceTest : BasePlatformTestCase() { } } - open class RefReferenceTest : BasePlatformTestCase() { fun testRefCommandReference() { myFixture.addFileToProject( diff --git a/src/test/resources/config/lets.mixin.yaml b/src/test/resources/config/lets.mixin.yaml deleted file mode 100644 index 43fd40b..0000000 --- a/src/test/resources/config/lets.mixin.yaml +++ /dev/null @@ -1,5 +0,0 @@ -shell: bash - -commands: - test: - cmd: echo Test diff --git a/src/test/resources/config/lets.yaml b/src/test/resources/config/lets.yaml deleted file mode 100644 index 14bff1e..0000000 --- a/src/test/resources/config/lets.yaml +++ /dev/null @@ -1,42 +0,0 @@ -shell: bash - -mixins: - - lets.mixin.yaml - - url: https://lets-cli.org/mixins/lets.mixin.yaml - version: 1 - -env: - DEBUG: false - DAY: - sh: `echo Moday` - SELF_CHECKSUM: - checksum: [lets.yaml] - SELF_CHECKSUM_MAP: - checksum: - self: - - lets.yaml - -before: echo Before -init: echo Init - -commands: - run: - depends: - - install - env: - DEV: true - UID: - sh: `echo 1` - cmd: echo Run - - install: - cmd: echo Install - - build: - cmd: - - echo - - Build - dev: - cmd: - app: echo App - db: echo Db From 142322ba6a5be958c12b8329b6d6d26b0f829555 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 8 Mar 2025 23:43:25 +0200 Subject: [PATCH 5/5] Refactor LetsCompletionHelper * Introduce YamlContextType enum * Rewrite context type detection (see detectContext) * Add tests for detectContext --- .../intellijlets/LetsCompletionHelper.kt | 79 +++++-------- .../intellijlets/LetsCompletionProvider.kt | 13 ++- .../kindermax/intellijlets/constants.kt | 3 - .../{CompleteEnv.kt => CompleteEnvTest.kt} | 0 ...pleteKeyword.kt => CompleteKeywordTest.kt} | 0 .../completion/DetectContextTest.kt | 107 ++++++++++++++++++ 6 files changed, 144 insertions(+), 58 deletions(-) rename src/test/kotlin/com/github/kindermax/intellijlets/completion/{CompleteEnv.kt => CompleteEnvTest.kt} (100%) rename src/test/kotlin/com/github/kindermax/intellijlets/completion/{CompleteKeyword.kt => CompleteKeywordTest.kt} (100%) create mode 100644 src/test/kotlin/com/github/kindermax/intellijlets/completion/DetectContextTest.kt diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionHelper.kt b/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionHelper.kt index 05e0921..4b6187a 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionHelper.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionHelper.kt @@ -1,69 +1,50 @@ package com.github.kindermax.intellijlets import com.intellij.codeInsight.completion.CompletionParameters -import com.intellij.psi.util.parentsOfType +import com.intellij.psi.PsiElement +import com.intellij.psi.util.PsiTreeUtil import org.jetbrains.yaml.psi.YAMLFile import org.jetbrains.yaml.psi.YAMLKeyValue object LetsCompletionHelper { - private fun isInTopLevelDirective(name: String, parameters: CompletionParameters): Boolean { - val yamlKeyValueParents = parameters.position.parentsOfType(false).toList() - - if (yamlKeyValueParents.size == 1) { - return yamlKeyValueParents[0].name == name - } - return false + sealed class YamlContextType { + object RootLevel : YamlContextType() + object CommandLevel : YamlContextType() + object ShellLevel : YamlContextType() + object DependsLevel : YamlContextType() + object RefLevel : YamlContextType() + object Unknown : YamlContextType() } - /** - * Check if current position is in command context. It means: - * commands: - * echo: - * | -> cursor is here - */ - fun isCommandLevel(parameters: CompletionParameters): Boolean { - val yamlKeyValueParents = parameters.position.parentsOfType(false).toList() + fun detectContext(position: PsiElement): YamlContextType { + if (isRootLevel(position)) return YamlContextType.RootLevel - if (yamlKeyValueParents.size == 2) { - return yamlKeyValueParents[1].name == "commands" - } - return false - } + val keyValue = PsiTreeUtil.getParentOfType(position, YAMLKeyValue::class.java) ?: return YamlContextType.Unknown - /** - * Check if current position is in shell context. It means: - * shell: | -> cursor is here - */ - fun isShellLevel(parameters: CompletionParameters): Boolean { - return isInTopLevelDirective("shell", parameters) + return when { + isInCommandKey(keyValue) -> YamlContextType.CommandLevel + isInTopLevelKey(keyValue) -> YamlContextType.RootLevel + keyValue.keyText == "shell" -> YamlContextType.ShellLevel + keyValue.keyText == "depends" -> YamlContextType.DependsLevel + keyValue.keyText == "ref" -> YamlContextType.RefLevel + else -> YamlContextType.Unknown + } } - fun isRootLevel(parameters: CompletionParameters): Boolean { + private fun isRootLevel(position: PsiElement): Boolean { return ( - parameters.position.parent.parent.parent is YAMLFile || - parameters.position.parent.parent.parent.parent is YAMLFile - ) + position.parent.parent.parent is YAMLFile || + position.parent.parent.parent.parent is YAMLFile + ) } - - // TODO: refactor this methods, find more idiomatic way to check the level - fun isDependsLevel(parameters: CompletionParameters): Boolean { - val yamlKeyValueParents = parameters.position.parentsOfType(false).toList() - - if (yamlKeyValueParents.size == DEPENDS_LEVEL) { - return yamlKeyValueParents[0].name == "depends" - } - - return false + private fun isInTopLevelKey(keyValue: YAMLKeyValue): Boolean { + return keyValue.keyText in TOP_LEVEL_KEYWORDS && keyValue.parent?.parent is YAMLFile } - fun isRefLevel(parameters: CompletionParameters): Boolean { - val yamlKeyValueParents = parameters.position.parentsOfType(false).toList() - - if (yamlKeyValueParents.size == REF_LEVEL) { - return yamlKeyValueParents[0].name == "ref" - } - - return false + private fun isInCommandKey(keyValue: YAMLKeyValue): Boolean { + val parentKeyValue = keyValue.parent?.parent as? YAMLKeyValue ?: return false + return parentKeyValue.keyText == "commands" +// && keyValue.keyText in COMMAND_LEVEL_KEYWORDS } /** diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionProvider.kt b/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionProvider.kt index cdb6298..e5f3c56 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionProvider.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/LetsCompletionProvider.kt @@ -17,8 +17,8 @@ object LetsCompletionProvider : CompletionProvider() { context: ProcessingContext, result: CompletionResultSet, ) { - when { - LetsCompletionHelper.isRootLevel(parameters) -> { + when (LetsCompletionHelper.detectContext(parameters.position)) { + LetsCompletionHelper.YamlContextType.RootLevel -> { val yamlFile = parameters.originalFile as YAMLFile val usedKeywords = LetsPsiUtils.getUsedKeywords(yamlFile) val suggestions = when (usedKeywords.size) { @@ -36,10 +36,10 @@ object LetsCompletionProvider : CompletionProvider() { } ) } - LetsCompletionHelper.isShellLevel(parameters) -> { + LetsCompletionHelper.YamlContextType.ShellLevel -> { result.addAllElements(DEFAULT_SHELLS.map { keyword -> createLookupElement(keyword) }) } - LetsCompletionHelper.isCommandLevel(parameters) -> { + LetsCompletionHelper.YamlContextType.CommandLevel -> { result.addAllElements( COMMAND_LEVEL_KEYWORDS.map { keyword -> when (keyword) { @@ -51,18 +51,19 @@ object LetsCompletionProvider : CompletionProvider() { } ) } - LetsCompletionHelper.isDependsLevel(parameters) -> { + LetsCompletionHelper.YamlContextType.DependsLevel -> { val suggestions = LetsCompletionHelper.getDependsSuggestions(parameters) result.addAllElements( suggestions.map { keyword -> createLookupElement(keyword) } ) } - LetsCompletionHelper.isRefLevel(parameters) -> { + LetsCompletionHelper.YamlContextType.RefLevel -> { val suggestions = LetsCompletionHelper.getRefSuggestions(parameters) result.addAllElements( suggestions.map { keyword -> createLookupElement(keyword) } ) } + LetsCompletionHelper.YamlContextType.Unknown -> return } } } diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/constants.kt b/src/main/kotlin/com/github/kindermax/intellijlets/constants.kt index db7e00c..6ffdecc 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/constants.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/constants.kt @@ -1,8 +1,5 @@ package com.github.kindermax.intellijlets -const val DEPENDS_LEVEL = 3 -const val REF_LEVEL = 3 - const val COMMAND_CHILD_PADDING = 6 const val ROOT_PADDING = 2 diff --git a/src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteEnv.kt b/src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteEnvTest.kt similarity index 100% rename from src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteEnv.kt rename to src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteEnvTest.kt diff --git a/src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteKeyword.kt b/src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteKeywordTest.kt similarity index 100% rename from src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteKeyword.kt rename to src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteKeywordTest.kt diff --git a/src/test/kotlin/com/github/kindermax/intellijlets/completion/DetectContextTest.kt b/src/test/kotlin/com/github/kindermax/intellijlets/completion/DetectContextTest.kt new file mode 100644 index 0000000..038212f --- /dev/null +++ b/src/test/kotlin/com/github/kindermax/intellijlets/completion/DetectContextTest.kt @@ -0,0 +1,107 @@ +package com.github.kindermax.intellijlets.completion + +import com.github.kindermax.intellijlets.LetsCompletionHelper +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +open class DetectContextTest : BasePlatformTestCase() { + fun testRootLevel() { + val file = myFixture.configureByText( + "lets.yaml", + """ + shell: bash + + + commands: + run: + cmd: Echo Run + """.trimIndent() + ) + val position = file.findElementAt(myFixture.caretOffset) + assertNotNull(position) + + val context = LetsCompletionHelper.detectContext(position!!) + assertEquals(context, LetsCompletionHelper.YamlContextType.RootLevel) + } + + fun testCommandLevel() { + val file = myFixture.configureByText( + "lets.yaml", + """ + shell: bash + + commands: + run: + c + """.trimIndent() + ) + val offset = myFixture.caretOffset + val position = file.findElementAt(offset - 1) + assertNotNull("PsiElement should not be null", position) + + val context = LetsCompletionHelper.detectContext(position!!) + assertEquals(context, LetsCompletionHelper.YamlContextType.CommandLevel) + } + + fun testShellLevel() { + val file = myFixture.configureByText( + "lets.yaml", + """ + shell: b + """.trimIndent() + ) + val offset = myFixture.caretOffset + val position = file.findElementAt(offset - 1) + assertNotNull("PsiElement should not be null", position) + + val context = LetsCompletionHelper.detectContext(position!!) + assertEquals(context, LetsCompletionHelper.YamlContextType.ShellLevel) + } + + fun testDependsLevel() { + val file = myFixture.configureByText( + "lets.yaml", + """ + shell: bash + commands: + hello: + cmd: echo Hello + depends: [world] + hi: + cmd: echo Hi + lol: + cmd: echo Lol + world: + depends: [] + cmd: echo World + """.trimIndent() + ) + val offset = myFixture.caretOffset + val position = file.findElementAt(offset) + assertNotNull("PsiElement should not be null", position) + + val context = LetsCompletionHelper.detectContext(position!!) + assertEquals(context, LetsCompletionHelper.YamlContextType.DependsLevel) + } + + fun testRefLevel() { + val file = myFixture.configureByText( + "lets.yaml", + """ + shell: bash + mixins: + - mixins/lets.mixin.yaml + + commands: + build-dev: + ref: b + args: --dev + """.trimIndent() + ) + val offset = myFixture.caretOffset + val position = file.findElementAt(offset - 1) + assertNotNull("PsiElement should not be null", position) + + val context = LetsCompletionHelper.detectContext(position!!) + assertEquals(context, LetsCompletionHelper.YamlContextType.RefLevel) + } +}